fix(owner): replace native Team Management select with custom dark-mode-aware project picker
This commit is contained in:
@@ -101,3 +101,46 @@ export function stormAttribution(kanbanLeads = [], projects = []) {
|
|||||||
revenuePct: totalRevenue ? Math.round((stormRevenue / totalRevenue) * 100) : 0,
|
revenuePct: totalRevenue ? Math.round((stormRevenue / totalRevenue) * 100) : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Owner Financial-Overview metrics, each carrying the underlying line items so the dashboard
|
||||||
|
// cards can drill down to the real records. Pass `today` (ISO date) to flag payouts due soon.
|
||||||
|
// - collected: cash received from clients (paid paymentSchedule entries)
|
||||||
|
// - outstandingAR: client money still owed (unpaid paymentSchedule entries)
|
||||||
|
// - pendingPayouts: money owed to vendors (pending invoices); dueSoon = within 7 days of `today`
|
||||||
|
// - vendorSpend: cash paid out to vendors (paid invoices)
|
||||||
|
export function financialOverview(projects = [], today) {
|
||||||
|
const now = today ? new Date(today) : null;
|
||||||
|
const within7 = (d) => {
|
||||||
|
if (!now || !d) return false;
|
||||||
|
const diff = (new Date(d) - now) / 86400000;
|
||||||
|
return diff >= 0 && diff <= 7;
|
||||||
|
};
|
||||||
|
const collected = { total: 0, items: [] };
|
||||||
|
const outstandingAR = { total: 0, count: 0, items: [] };
|
||||||
|
const pendingPayouts = { total: 0, dueSoonCount: 0, dueSoonTotal: 0, items: [] };
|
||||||
|
const vendorSpend = { total: 0, items: [] };
|
||||||
|
for (const p of projects) {
|
||||||
|
const project = p.customerName || p.projectType || p.id;
|
||||||
|
for (const ps of p.paymentSchedule || []) {
|
||||||
|
if (ps.status === 'paid') {
|
||||||
|
collected.total += ps.amount || 0;
|
||||||
|
collected.items.push({ projectId: p.id, project, label: ps.milestone, amount: ps.amount || 0, date: ps.paidDate || ps.dueDate });
|
||||||
|
} else {
|
||||||
|
outstandingAR.total += ps.amount || 0;
|
||||||
|
outstandingAR.count += 1;
|
||||||
|
outstandingAR.items.push({ projectId: p.id, project, label: ps.milestone, amount: ps.amount || 0, dueDate: ps.dueDate });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const inv of p.invoices || []) {
|
||||||
|
if (inv.status === 'pending') {
|
||||||
|
pendingPayouts.total += inv.amount || 0;
|
||||||
|
pendingPayouts.items.push({ projectId: p.id, project, label: inv.submittedBy, amount: inv.amount || 0, dueDate: inv.dueDate });
|
||||||
|
if (within7(inv.dueDate)) { pendingPayouts.dueSoonCount += 1; pendingPayouts.dueSoonTotal += inv.amount || 0; }
|
||||||
|
} else if (inv.status === 'paid') {
|
||||||
|
vendorSpend.total += inv.amount || 0;
|
||||||
|
vendorSpend.items.push({ projectId: p.id, project, label: inv.submittedBy, amount: inv.amount || 0, date: inv.datePaid });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { collected, outstandingAR, pendingPayouts, vendorSpend };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
@@ -15,9 +15,63 @@ import {
|
|||||||
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import {
|
import {
|
||||||
Briefcase, TrendingUp, AlertTriangle, Shield, ChevronRight, ArrowUpRight, Users, ChevronDown
|
Briefcase, TrendingUp, AlertTriangle, Shield, ChevronRight, ArrowUpRight, Users, ChevronDown, Check
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
// Custom dark-mode-aware project picker (replaces the native <select> whose option list
|
||||||
|
// renders white-on-white in dark mode). Mirrors the FilterPopover pattern used elsewhere.
|
||||||
|
function ProjectPicker({ projects, value, onChange }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const boxRef = useRef(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClick = (e) => { if (boxRef.current && !boxRef.current.contains(e.target)) setOpen(false); };
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('mousedown', onClick);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => { document.removeEventListener('mousedown', onClick); document.removeEventListener('keydown', onKey); };
|
||||||
|
}, [open]);
|
||||||
|
const labelFor = (p) => `${p.projectType} — ${p.address?.split(',')[0] || p.id}`;
|
||||||
|
const selected = projects.find(p => p.id === value);
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={boxRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label="Select project"
|
||||||
|
onClick={() => setOpen(o => !o)}
|
||||||
|
className="w-full sm:w-auto min-w-[220px] max-w-[300px] flex items-center justify-between gap-2 pl-3 pr-2 py-2 text-sm font-semibold rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:ring-2 focus:ring-violet-500/20 focus:border-violet-500/40 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="truncate text-left">{selected ? labelFor(selected) : 'Select project'}</span>
|
||||||
|
<ChevronDown size={14} className={`shrink-0 text-zinc-400 transition-transform ${open ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div role="listbox" className="absolute z-30 right-0 mt-1 w-[300px] max-w-[80vw] rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 shadow-xl shadow-black/10 dark:shadow-black/50 overflow-hidden">
|
||||||
|
<div className="max-h-72 overflow-y-auto custom-scrollbar py-1">
|
||||||
|
{projects.map(p => {
|
||||||
|
const isSelected = p.id === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={() => { onChange(p.id); setOpen(false); }}
|
||||||
|
className={`w-full flex items-center justify-between gap-2 px-3 py-2 text-sm text-left transition-colors ${isSelected ? 'text-violet-600 dark:text-violet-400 bg-violet-50 dark:bg-violet-500/10' : 'text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-white/5'}`}
|
||||||
|
>
|
||||||
|
<span className="truncate">{labelFor(p)}</span>
|
||||||
|
{isSelected && <Check size={12} strokeWidth={2.5} className="shrink-0 opacity-70" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const OwnerSnapshot = () => {
|
const OwnerSnapshot = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -491,20 +545,11 @@ const OwnerSnapshot = () => {
|
|||||||
<span className="w-2 h-8 bg-violet-500 rounded-full mr-3"></span>
|
<span className="w-2 h-8 bg-violet-500 rounded-full mr-3"></span>
|
||||||
Team Management
|
Team Management
|
||||||
</h2>
|
</h2>
|
||||||
<div className="relative">
|
<ProjectPicker
|
||||||
<select
|
projects={ownerProjects}
|
||||||
value={selectedProject.id}
|
value={selectedProject.id}
|
||||||
onChange={e => setSelectedProjectId(e.target.value)}
|
onChange={setSelectedProjectId}
|
||||||
className="appearance-none pl-3 pr-8 py-2 text-sm font-semibold rounded-xl bg-zinc-100 dark:bg-white/5 border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white outline-none focus:border-violet-500 transition-colors cursor-pointer"
|
/>
|
||||||
>
|
|
||||||
{ownerProjects.map(p => (
|
|
||||||
<option key={p.id} value={p.id}>
|
|
||||||
{p.projectType} — {p.address?.split(',')[0] || p.id}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-zinc-400 pointer-events-none" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<SpotlightCard className="p-5 lg:p-6" spotlightColor="rgba(139, 92, 246, 0.06)">
|
<SpotlightCard className="p-5 lg:p-6" spotlightColor="rgba(139, 92, 246, 0.06)">
|
||||||
<TeamManagementPanel
|
<TeamManagementPanel
|
||||||
|
|||||||
Reference in New Issue
Block a user