From ac3f08122db2ebd33104705c05e265b2127e160a Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Mon, 1 Jun 2026 10:48:40 -0400 Subject: [PATCH] feat: finance page overhaul, file access fix for clients and subcontractors - Finance (TeamInvoices): chart + stat cards, Overview/Expenses/Subcontractors/Invoices/Legacy tabs - Expenses tab: 70/30 card split with expense list + category breakdown, + Expense button - Overview tab: 3 cards dynamic height filling window, .md-compliant table headers and sticky heads - TaskDetail FileBrowser: role-based virtual paths so clients and subcontractors no longer get 403 Co-Authored-By: Claude Sonnet 4.6 --- layout.md | 48 ++++- src/components/FileBrowser.jsx | 24 +-- src/components/Layout.jsx | 9 +- src/index.css | 17 +- src/pages/ProjectDetail.jsx | 8 +- src/pages/TaskDetail.jsx | 43 ++-- src/pages/team/TeamDashboard.jsx | 17 +- src/pages/team/TeamInvoices.jsx | 326 +++++++++++++++++++++++++++---- 8 files changed, 401 insertions(+), 91 deletions(-) diff --git a/layout.md b/layout.md index 9f0afbf..ac60e7b 100644 --- a/layout.md +++ b/layout.md @@ -60,6 +60,14 @@ This is the single source of truth for dashboard/profile visual structure and UI - Theme toggle to avatar: `14px` (`avatar-wrap margin-left`) - Avatar button: `49x49`, circle, `2px` inner ring + `2px` accent outline +## 6.5) Section Control Bars (Tabs + Actions) +- For page-level card controls (ex: Tasks/Projects, Finances tabs): + - container uses: `display: flex`, `align-items: center`, `gap: 4`, `margin-bottom: 10`, `flex-shrink: 0` + - tabs stay on the left in source order + - action buttons group sits on the right using: `margin-left: auto`, `display: flex`, `align-items: center`, `gap: 8` + - do not use hardcoded spacer blocks (`width` filler divs) to force alignment + - icon-only filter/action buttons share the same row and align vertically with add buttons + ## 7) Dashboard Grids (Team) - Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0` - Row 2: `grid-template-columns: 1fr 1fr 280px`, `gap: 24`, `margin-top: 24` @@ -147,7 +155,7 @@ This is the single source of truth for dashboard/profile visual structure and UI - company line under title is always visible; fallback display is `—` when no company is assigned - right-side meta labels (`Member Since`, `Role`) use widget-title sizing: `11px`, `500`, uppercase, `letter-spacing: 0.8px` - right-side meta values use body sizing: `13px` - - self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 30px`, `font-size: 12px`, `padding: 0 12px` + - self-only edit button: `position: absolute`, `top: 18px`, `right: 21px` (aligns to card padding), `border-radius: 8px` (matches card), `height: 22px`, `font-size: 12px`, `padding: 0 12px` - default outline button hover/focus fills with accent gold and uses dark text - Right calendar card shows only tasks/events assigned to the viewed profile user - Profile page left column includes a `Tasks In Progress` card above `Recent Activity` @@ -196,7 +204,7 @@ This is the single source of truth for dashboard/profile visual structure and UI - current file path uses the same `11px` uppercase label treatment as the title row family - toolbar does not show `+ Folder` or `Upload` buttons - upload happens by dragging and dropping files or folders into the right card - - delete action uses the same geometry as the profile edit button (`top: 18px`, `right: 21px`, `8px` radius, `30px` height, `12px` text, `0 12px` padding) but uses danger styling with red hover/fill + - delete action uses the same geometry as the profile edit button (`top: 18px`, `right: 21px`, `8px` radius, `22px` height, `12px` text, `0 12px` padding) but uses danger styling with red hover/fill - Right card table: - uses dashboard table styling (`table-layout: fixed`, transparent header row, `10px` uppercase headers, `13px/12px` body text) - card padding matches dashboard widget shell (`18px 21px`) @@ -216,7 +224,15 @@ This is the single source of truth for dashboard/profile visual structure and UI - Dashboard/profile widgets: `8px` radius - Sidebar: `8px` radius - Standard app buttons use the `Edit Profile` geometry: - - height: `30px` + - geometry is tokenized globally in CSS vars and must be reused (no per-page hardcoded button geometry): + - `--btn-height` + - `--btn-padding-x` + - `--btn-radius` + - `--btn-font-size` + - `--btn-font-weight` + - `--btn-letter-spacing` + - `--btn-line-height` + - height: `22px` - horizontal padding: `0 12px` - border-radius: `8px` - font-size: `11px` @@ -269,6 +285,32 @@ This is the single source of truth for dashboard/profile visual structure and UI - field surfaces and borders use global theme tokens (`var(--card-bg-2)` + `var(--border)`) for dark/light parity - modal form action buttons use the standard outline geometry/style (`btn btn-outline`) unless a page explicitly defines a different role +## 14.7) Status Tags +- Shared status tags use `StatusBadge` (`.badge.badge-status`). +- Base badge geometry: + - `display: inline-flex` + - `align-items: center` + - `gap: 4px` + - `padding: 3px 10px` + - `border-radius: 4px` + - `font-size: 11px` + - `font-weight: 400` + - `letter-spacing: 0.3px` + - `white-space: nowrap` +- Status badge geometry override (`.badge-status`): + - `min-width: 78px` + - `height: 20px` + - `justify-content: center` + - `padding: 0 8px` + - `font-size: 10px` + - `line-height: 1` +- Status colors are variant classes (`.badge-not_started`, `.badge-in_progress`, etc.) and are theme-aware. +- Exception: compact urgent tag (`.badge-needs_revision`) uses tighter geometry: + - `min-width: 28px` + - `padding: 3px 4px` + - `border-radius: 4px` + - fixed red treatment in both dark/light themes. + ## 15) Motion - Motion vars: - fast `160ms` diff --git a/src/components/FileBrowser.jsx b/src/components/FileBrowser.jsx index bb461fd..1067e62 100644 --- a/src/components/FileBrowser.jsx +++ b/src/components/FileBrowser.jsx @@ -349,26 +349,26 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
{uploadProgress !== null && Uploading {uploadProgress}%} {selected.size > 0 && ( - + )} {!readOnly && (showFolderInput ? (
setFolderName(e.target.value)} placeholder="Folder name" disabled={Boolean(working)} style={{ fontSize: 12, padding: '3px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg-2)', color: 'var(--text-primary)', width: 140 }} /> - Create - + Create +
) : ( - + ))} {!readOnly && ( <> uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} /> uploadFiles(e.target.files)} /> - folderInputRef.current?.click()}>↑ Folder - fileInputRef.current?.click()}>↑ Files + folderInputRef.current?.click()}>↑ Folder + fileInputRef.current?.click()}>↑ Files )} - loadFiles(currentPath)}>⟳ + loadFiles(currentPath)}>⟳
@@ -439,8 +439,8 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { {isRenaming ? (
e.stopPropagation()}> setRenameValue(e.target.value)} disabled={Boolean(working)} onKeyDown={e => { if (e.key === 'Escape') setRenamingEntry(null); }} style={{ fontSize: 13, padding: '2px 8px', borderRadius: 4, border: '1px solid var(--border)', background: 'var(--card-bg-2)', color: 'var(--text-primary)', flex: 1, minWidth: 0 }} /> - Save - + Save +
) : (
@@ -459,12 +459,12 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) { {!isRenaming && !readOnly && (
{!isDir && ( - downloadFile(entry)}>↓ + downloadFile(entry)}>↓ )} - - +
)} diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 4ae40d5..d4ad85c 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -147,13 +147,16 @@ export default function Layout({ children }) { const isFileSharingRoute = location.pathname === '/file-sharing'; const isTaskDetailRoute = location.pathname.startsWith('/tasks/') || location.pathname.startsWith('/projects/') || location.pathname.startsWith('/requests/'); const isRequestsRoute = location.pathname === '/tasks' || location.pathname === '/requests' || location.pathname === '/team/tasks' || isTaskDetailRoute; - const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null; + const isFinancesRoute = location.pathname === '/invoices' || location.pathname.startsWith('/invoices/') || location.pathname === '/sub-invoices' || location.pathname.startsWith('/sub-invoices/'); + const headerTitle = isProfileRoute ? 'Profile' : isFileSharingRoute ? 'File Sharing' : isFinancesRoute ? 'Finances' : isRequestsRoute && !isTaskDetailRoute ? 'Tasks & Projects' : !isTaskDetailRoute ? `Good ${timeOfDay}${firstName ? `, ${firstName}` : ''}` : null; const headerSubtitle = isProfileRoute ? 'Account details and security settings.' : isFileSharingRoute ? 'Browse, share and manage files.' - : isRequestsRoute && !isTaskDetailRoute - ? 'Browse and manage all tasks and projects.' + : isFinancesRoute + ? 'Invoices, expenses and subcontractor POs.' + : isRequestsRoute && !isTaskDetailRoute + ? 'Browse and manage all tasks and projects.' : isTaskDetailRoute ? null : "Here's what's happening today."; useEffect(() => { diff --git a/src/index.css b/src/index.css index 7c8d2b0..2561e4f 100644 --- a/src/index.css +++ b/src/index.css @@ -11,7 +11,14 @@ --motion-fast: 160ms; --motion-base: 220ms; --motion-ease: cubic-bezier(0.22, 1, 0.36, 1); - --h-control: 30px; + --btn-height: 22px; + --btn-padding-x: 12px; + --btn-radius: 8px; + --btn-font-size: 11px; + --btn-font-weight: 500; + --btn-letter-spacing: 0.8px; + --btn-line-height: 1; + --h-control: var(--btn-height); --sidebar-bg: #0d0d0d; --sidebar-text: #888888; --sidebar-active-text: #ffffff; @@ -997,10 +1004,10 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); } /* Buttons */ .btn { display: inline-flex; align-items: center; gap: 6px; - height: var(--h-control); padding: 0 12px; border-radius: 8px; font-size: 11px; - font-weight: 500; cursor: pointer; border: 1px solid transparent; + height: var(--btn-height); padding: 0 var(--btn-padding-x); border-radius: var(--btn-radius); font-size: var(--btn-font-size); + font-weight: var(--btn-font-weight); cursor: pointer; border: 1px solid transparent; transition: all 0.15s; text-decoration: none; white-space: nowrap; - font-family: inherit; line-height: 1; letter-spacing: 0.8px; text-transform: uppercase; + font-family: inherit; line-height: var(--btn-line-height); letter-spacing: var(--btn-letter-spacing); text-transform: uppercase; } .btn:disabled { opacity: 0.65; @@ -1024,7 +1031,7 @@ input.site-header-search[type="text"]:focus { border-color: var(--accent); } @keyframes btn-spin { to { transform: rotate(360deg); } } -.btn-sm { padding: 0 12px; font-size: 11px; line-height: 1; letter-spacing: 0.8px; text-transform: uppercase; } +.btn-sm { padding: 0 var(--btn-padding-x); font-size: var(--btn-font-size); line-height: var(--btn-line-height); letter-spacing: var(--btn-letter-spacing); text-transform: uppercase; } .btn-primary { background: var(--accent); color: #111111; } .btn-primary:hover { background: var(--accent-hover); } .btn-outline { background: transparent; color: var(--text-primary); border: 1px solid var(--border); } diff --git a/src/pages/ProjectDetail.jsx b/src/pages/ProjectDetail.jsx index 4f2e089..756ec73 100644 --- a/src/pages/ProjectDetail.jsx +++ b/src/pages/ProjectDetail.jsx @@ -373,7 +373,7 @@ export default function ProjectDetailPage() { ); })} {activeTab === 'members' && isTeam && ( - + )}
); @@ -484,7 +484,7 @@ export default function ProjectDetailPage() { {m.profile?.avatar_url ?
{m.profile.name}
- :
{initials}
+ :
{initials}
} {m.profile?.name || '—'} @@ -669,8 +669,8 @@ export default function ProjectDetailPage() { {p.avatar_url ?
{p.name}
- :
- {initials} + :
+ {initials}
} diff --git a/src/pages/TaskDetail.jsx b/src/pages/TaskDetail.jsx index 7c0c4f5..aa2ecca 100644 --- a/src/pages/TaskDetail.jsx +++ b/src/pages/TaskDetail.jsx @@ -192,7 +192,7 @@ function SubmissionFiles({ files, downloading, onDownloadAll }) { className="btn btn-outline" disabled={Boolean(downloading)} onClick={() => onDownloadAll(files)} - style={{ marginTop: 10, fontSize: 10, padding: '2px 10px', letterSpacing: 0.6, textTransform: 'uppercase' }} + style={{ marginTop: 10 }} > {downloading === 'all' ? 'Downloading…' : 'Download All'} @@ -416,7 +416,7 @@ export default function TaskDetail() {
{task.title}
{isTeamOrSub && status === 'not_started' && ( - + )} {isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && ( <> @@ -438,13 +438,13 @@ export default function TaskDetail() { )} {isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && ( - + )}
{submission?.service_type && ( - {submission.service_type} + {submission.service_type} )}
@@ -529,7 +529,7 @@ export default function TaskDetail() { {isClient && ['not_started', 'in_progress'].includes(status) && (
- +
)} @@ -596,7 +596,7 @@ export default function TaskDetail() { {isClient && i === 0 && ['not_started', 'in_progress'].includes(status) && (
- +
)} @@ -632,7 +632,7 @@ export default function TaskDetail() { onKeyDown={e => { if (e.key === 'Enter') handlePostComment(); }} style={{ flex: 1, fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', fontFamily: 'inherit', height: 30 }} /> - + {comments.length === 0 ?
No comments yet.
@@ -649,7 +649,7 @@ export default function TaskDetail() { {dateStr} · {timeStr} {isOwn && ( - + )}
{c.body}
@@ -659,12 +659,19 @@ export default function TaskDetail() { } )} - {activeTab === 'Folder' && company?.name && project?.name && ( - - )} + {activeTab === 'Folder' && company?.name && project?.name && (() => { + const fbInitial = role === 'external' + ? `/Projects/${safeName(project.name)}/${safeName(task.title)}` + : role === 'client' + ? `/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}` + : `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`; + const fbRoot = role === 'external' + ? `/Projects/${safeName(project.name)}` + : role === 'client' + ? `/${safeName(company.name)}/Projects/${safeName(project.name)}` + : `/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}`; + return ; + })()} {activeTab === 'Folder' && (!company?.name || !project?.name) && (
No folder available — task needs a project and company.
)} @@ -729,7 +736,7 @@ export default function TaskDetail() {
setRevisionFiles(prev => [...prev, ...Array.from(e.target.files)])} /> - + {revisionFiles.length > 0 && (
{revisionFiles.map((f, i) => ( @@ -742,8 +749,8 @@ export default function TaskDetail() { )}
- - +
@@ -781,7 +788,7 @@ export default function TaskDetail() { {amendFiles.map((f, i) => (
{f.name} - +
))}
diff --git a/src/pages/team/TeamDashboard.jsx b/src/pages/team/TeamDashboard.jsx index 6f9632e..f87022d 100644 --- a/src/pages/team/TeamDashboard.jsx +++ b/src/pages/team/TeamDashboard.jsx @@ -76,10 +76,9 @@ function ActionIcon({ actionKey, size = 27 }) { } function Avatar({ name, size = 27 }) { - const tone = iconTone(name); return ( -
- +
+ @@ -88,10 +87,9 @@ function Avatar({ name, size = 27 }) { } function InitialPortrait({ name }) { - const tone = iconTone(name); return ( -
- {(name || '?')[0].toUpperCase()} +
+ {(name || '?')[0].toUpperCase()}
); } @@ -592,18 +590,17 @@ function TeamPerformanceCard({ tasks, profiles }) {
{people.slice(0, 5).map((p, i) => { const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0; - const tone = iconTone(p.name); return (
0 ? 10 : 0 }}> - {(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ?
{p.name}
: ; })()} + {(() => { const prof = p.id ? profileMap.get(p.id) : null; return prof?.avatar_url ?
{p.name}
: ; })()}
{p.id ? : {p.name}} {p.newCount} new · {p.revCount} revision
-
-
+
+
{pct}%
diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx index 065c0dc..9bd30df 100644 --- a/src/pages/team/TeamInvoices.jsx +++ b/src/pages/team/TeamInvoices.jsx @@ -30,7 +30,7 @@ const poStatusLabel = { cancelled: 'Cancelled', }; const RECEIPT_BUCKET = 'expense-receipts'; -const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 400, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.5, display: 'block', marginBottom: 4 }; +const FIELD_LABEL_STYLE = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 4 }; const FIELD_INPUT_STYLE = { minHeight: 42, margin: 0 }; const blankExpense = () => ({ @@ -71,10 +71,14 @@ export default function Invoices() { const [invoices, setInvoices] = useState(() => cached?.invoices || []); const [loading, setLoading] = useState(() => !cached); const [activeTab, setActiveTab] = useState(() => location.state?.tab || 'invoices'); + const [financeTab, setFinanceTab] = useState('overview'); const [filter, setFilter] = useState('all'); const { sortKey: invSortKey, sortDir: invSortDir, toggle: invToggle, sort: invSort } = useSortable('invoice_date'); const { sortKey: expSortKey, sortDir: expSortDir, toggle: expToggle, sort: expSort } = useSortable('date'); const { sortKey: subSortKey, sortDir: subSortDir, toggle: subToggle, sort: subSort } = useSortable('submitted_at'); + const { sortKey: ovExpKey, sortDir: ovExpDir, toggle: ovExpToggle } = useSortable('date'); + const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date'); + const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date'); const [filterCompany, setFilterCompany] = useState(''); const [exportYear, setExportYear] = useState(new Date().getFullYear()); const [exporting, setExporting] = useState(false); @@ -498,46 +502,294 @@ export default function Invoices() { return ( -
-
- -
Finances
-
{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} · {expenses.length} expense{expenses.length !== 1 ? 's' : ''} · {subcontractorPOs.length} subcontractor PO{subcontractorPOs.length !== 1 ? 's' : ''}
-
-
- {paidYears.length > 0 && ( - +
+ {/* Top row: 60% chart + 40% stat cards */} + {(() => { + const yr = chartData.reduce((s, m) => s + m.Revenue, 0); + const yp = chartData.reduce((s, m) => s + m.Profit, 0); + const ye = chartData.reduce((s, m) => s + m.Expenses, 0); + const yo = chartData.reduce((s, m) => s + m.Outstanding, 0); + const fmt = v => v >= 1000 ? `$${(v/1000).toFixed(1)}k` : `$${v.toFixed(0)}`; + const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; + const StatCard = ({ label, value, sub, iconBg, iconColor, iconSvg }) => ( +
+
+
{label}
+
+
{value}
+
+ {sub &&
{sub}
} +
+
+
+ {iconSvg} +
+
+
+ ); + return ( +
+
+
+ Revenue Overview + +
+ + + + + + + + + + + `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} /> + } /> + + + + + + + +
+
+ } /> + } /> + } /> + } /> +
+
+ ); + })()} + +
+
+ {[ + { id: 'overview', label: 'Overview' }, + { id: 'expenses', label: 'Expenses' }, + { id: 'subcontractors', label: 'Subcontractors' }, + { id: 'invoices', label: 'Invoices' }, + { id: 'legacy', label: 'Legacy' }, + ].map(t => ( + + ))} + {financeTab === 'expenses' && ( +
+ +
)} -
+
-
- - - - - - - - - - - `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} /> - } /> - - - - - - - -
+ {financeTab === 'overview' && (() => { + const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; + const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; + const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; + const sortRows = (arr, key, dir, getter) => [...arr].sort((a, b) => { + const av = getter(a, key), bv = getter(b, key); + if (av < bv) return dir === 'asc' ? -1 : 1; + if (av > bv) return dir === 'asc' ? 1 : -1; + return 0; + }); + const recentExp = sortRows(expenses, ovExpKey, ovExpDir, (e, k) => { + if (k === 'description') return (e.description || '').toLowerCase(); + if (k === 'category') return (e.category || '').toLowerCase(); + if (k === 'amount') return Number(e.amount) || 0; + return e.date || ''; + }).slice(0, 5); + const recentSub = sortRows(subcontractorPOs, ovSubKey, ovSubDir, (po, k) => { + if (k === 'name') return (po.profile?.name || '').toLowerCase(); + if (k === 'project') return (po.project?.name || '').toLowerCase(); + if (k === 'amount') return Number(po.amount) || 0; + return po.paid_at || po.date || ''; + }).slice(0, 5); + const recentInv = sortRows(invoices, ovInvKey, ovInvDir, (inv, k) => { + if (k === 'company') return (inv.company?.name || '').toLowerCase(); + if (k === 'total') return Number(inv.total) || 0; + return inv.invoice_date || ''; + }).slice(0, 5); + const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'; + const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + return ( +
+ {/* Recent Expenses */} +
+
Recent Expenses
+ {recentExp.length === 0 ?
No expenses
: ( +
+ + + + Description + Category + Amount + + {recentExp.map(e => ( + + + + + + ))} +
{e.description || '—'}{e.category || '—'}{fmtAmt(e.amount)}
+
+ )} +
+ {/* Recent Subcontractor Payments */} +
+
Recent Subcontractor Payments
+ {recentSub.length === 0 ?
No payments
: ( +
+ + + + Name + Project + Amount + + {recentSub.map(po => ( + + + + + + ))} +
{po.profile?.name || '—'}{po.project?.name || '—'}{fmtAmt(po.amount)}
+
+ )} +
+ {/* Recent Invoices */} +
+
Recent Invoices
+ {recentInv.length === 0 ?
No invoices
: ( +
+ + + + Company + Date + Total + + {recentInv.map(inv => ( + + + + + + ))} +
{inv.company?.name || '—'}{fmtDate(inv.invoice_date)}{fmtAmt(inv.total)}
+
+ )} +
+
+ ); + })()} + {financeTab === 'expenses' && (() => { + const CARD = { background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 8, padding: '18px 21px', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)' }; + const TH = { fontSize: 10, fontWeight: 500, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: 0.6, padding: '0 0 12px 0', border: 'none', background: 'transparent', textAlign: 'left' }; + const TD = { fontSize: 13, color: 'var(--text-primary)', padding: '5px 0', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; + const fmtDate = d => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '—'; + const fmtAmt = v => `$${Number(v).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + const sortedExpenses = expSort(expenses, (exp, key) => { + if (key === 'amount') return Number(exp.amount) || 0; + if (key === 'date') return exp.date || ''; + return exp[key] || ''; + }); + const categoryTotals = CATEGORIES + .map(cat => ({ cat, total: expenses.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })) + .filter(x => x.total > 0) + .sort((a, b) => b.total - a.total); + const grandTotal = expenses.reduce((s, e) => s + Number(e.amount || 0), 0); + return ( +
+
+ {/* Left: expense list */} +
+
Expenses
+ {expenses.length === 0 ?
No expenses
: ( +
+ + + + + + + + + + Date + Description + Category + Notes + Amount + + {sortedExpenses.map(exp => ( + startEditExpense(exp)}> + + + + + + + ))} +
{fmtDate(exp.date)}{exp.description || '—'}{exp.category || '—'}{exp.notes || '—'}{fmtAmt(exp.amount)}
+
+ )} +
+ {/* Right: category breakdown */} +
+
By Category
+ {categoryTotals.length === 0 ?
No expenses
: ( +
+
+ {categoryTotals.map(({ cat, total }) => { + const pct = grandTotal > 0 ? (total / grandTotal) * 100 : 0; + return ( +
+
+ {cat} + {fmtAmt(total)} +
+
+
+
+
+ ); + })} +
+ Total + {fmtAmt(grandTotal)} +
+
+
+ )} +
+
+
+ ); + })()} + + {financeTab === 'legacy' &&
{[ { id: 'invoices', label: 'INVOICES' }, @@ -886,7 +1138,9 @@ export default function Invoices() {
)} +
}{/* end legacy wrapper */} +
{/* end flex column wrapper */} ); }