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 <noreply@anthropic.com>
This commit is contained in:
Krao Hasanee
2026-06-01 10:48:40 -04:00
parent 70ad8d0cef
commit ac3f08122d
8 changed files with 401 additions and 91 deletions
+45 -3
View File
@@ -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`
+12 -12
View File
@@ -349,26 +349,26 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexShrink: 0 }}>
{uploadProgress !== null && <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>Uploading {uploadProgress}%</span>}
{selected.size > 0 && (
<button className="btn btn-danger" style={{ fontSize: 11, padding: '3px 10px' }} onClick={deleteSelected}>Delete ({selected.size})</button>
<button className="btn btn-danger" onClick={deleteSelected}>Delete ({selected.size})</button>
)}
{!readOnly && (showFolderInput ? (
<form style={{ display: 'flex', gap: 6 }} onSubmit={createFolder}>
<input autoFocus type="text" value={folderName} onChange={e => 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 }} />
<LoadingButton type="submit" className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} loading={working === 'mkdir'} disabled={!folderName.trim() || Boolean(working)} loadingText="…">Create</LoadingButton>
<button type="button" className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
<LoadingButton type="submit" className="btn btn-outline" loading={working === 'mkdir'} disabled={!folderName.trim() || Boolean(working)} loadingText="…">Create</LoadingButton>
<button type="button" className="btn btn-outline" onClick={() => { setShowFolderInput(false); setFolderName(''); }}>Cancel</button>
</form>
) : (
<button className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} disabled={Boolean(working)} onClick={() => setShowFolderInput(true)}>+ Folder</button>
<button className="btn btn-outline" disabled={Boolean(working)} onClick={() => setShowFolderInput(true)}>+ Folder</button>
))}
{!readOnly && (
<>
<input ref={folderInputRef} type="file" style={{ display: 'none' }} onChange={e => uploadFolder(e.target.files)} {...{ webkitdirectory: '' }} />
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={e => uploadFiles(e.target.files)} />
<LoadingButton className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} loading={working === 'upload'} disabled={Boolean(working)} loadingText="Uploading…" onClick={() => folderInputRef.current?.click()}> Folder</LoadingButton>
<LoadingButton className="btn btn-primary" style={{ fontSize: 11, padding: '3px 10px' }} loading={working === 'upload'} disabled={Boolean(working)} loadingText="Uploading…" onClick={() => fileInputRef.current?.click()}> Files</LoadingButton>
<LoadingButton className="btn btn-outline" loading={working === 'upload'} disabled={Boolean(working)} loadingText="Uploading…" onClick={() => folderInputRef.current?.click()}> Folder</LoadingButton>
<LoadingButton className="btn btn-primary" loading={working === 'upload'} disabled={Boolean(working)} loadingText="Uploading…" onClick={() => fileInputRef.current?.click()}> Files</LoadingButton>
</>
)}
<LoadingButton className="btn btn-outline" style={{ fontSize: 11, padding: '3px 10px' }} loading={loading} disabled={Boolean(working)} loadingText="…" onClick={() => loadFiles(currentPath)}></LoadingButton>
<LoadingButton className="btn btn-outline" loading={loading} disabled={Boolean(working)} loadingText="…" onClick={() => loadFiles(currentPath)}></LoadingButton>
</div>
</div>
@@ -439,8 +439,8 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
{isRenaming ? (
<form style={{ display: 'flex', gap: 6 }} onSubmit={renameEntry} onClick={e => e.stopPropagation()}>
<input autoFocus type="text" value={renameValue} onChange={e => 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 }} />
<LoadingButton type="submit" className="btn btn-outline" style={{ fontSize: 11, padding: '2px 8px' }} loading={working === `ren:${entry.path}`} disabled={!renameValue.trim()} loadingText="…">Save</LoadingButton>
<button type="button" className="btn btn-outline" style={{ fontSize: 11, padding: '2px 8px' }} onClick={() => setRenamingEntry(null)}>Cancel</button>
<LoadingButton type="submit" className="btn btn-outline" loading={working === `ren:${entry.path}`} disabled={!renameValue.trim()} loadingText="…">Save</LoadingButton>
<button type="button" className="btn btn-outline" onClick={() => setRenamingEntry(null)}>Cancel</button>
</form>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
@@ -459,12 +459,12 @@ export default function FileBrowser({ initialPath = '/', rootPath = '/' }) {
{!isRenaming && !readOnly && (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
{!isDir && (
<LoadingButton className="btn btn-outline" style={{ fontSize: 10, padding: '1px 8px', height: 22 }} loading={working === `dl:${entry.path}`} disabled={Boolean(working)} loadingText="↓" onClick={() => downloadFile(entry)}></LoadingButton>
<LoadingButton className="btn btn-outline" loading={working === `dl:${entry.path}`} disabled={Boolean(working)} loadingText="↓" onClick={() => downloadFile(entry)}></LoadingButton>
)}
<button className="btn btn-outline" style={{ fontSize: 10, padding: '1px 8px', height: 22 }} disabled={Boolean(working)} onClick={() => { setRenamingEntry(entry); setRenameValue(entry.name); }}>
<button className="btn btn-outline" disabled={Boolean(working)} onClick={() => { setRenamingEntry(entry); setRenameValue(entry.name); }}>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button className="btn btn-danger" style={{ fontSize: 10, padding: '1px 8px', height: 22 }} disabled={Boolean(working)} onClick={() => deleteEntry(entry)}></button>
<button className="btn btn-danger" disabled={Boolean(working)} onClick={() => deleteEntry(entry)}></button>
</div>
)}
</td>
+4 -1
View File
@@ -147,11 +147,14 @@ 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.'
: isFinancesRoute
? 'Invoices, expenses and subcontractor POs.'
: isRequestsRoute && !isTaskDetailRoute
? 'Browse and manage all tasks and projects.'
: isTaskDetailRoute ? null : "Here's what's happening today.";
+12 -5
View File
@@ -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); }
+4 -4
View File
@@ -373,7 +373,7 @@ export default function ProjectDetailPage() {
);
})}
{activeTab === 'members' && isTeam && (
<button className="btn btn-outline" style={{ marginLeft: 'auto', fontSize: 11, padding: '3px 12px' }} onClick={() => { const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id)); setSelectedExts(currentIds); setAddingMember(true); }}>Manage</button>
<button className="btn btn-outline" style={{ marginLeft: 'auto' }} onClick={() => { const currentIds = new Set(members.filter(m => m.profile?.role === 'external').map(m => m.profile_id)); setSelectedExts(currentIds); setAddingMember(true); }}>Manage</button>
)}
</div>
);
@@ -484,7 +484,7 @@ export default function ProjectDetailPage() {
<td style={{ ...TD_BASE }}>
{m.profile?.avatar_url
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={m.profile.avatar_url} alt={m.profile.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1 }}>{initials}</span></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}><span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span></div>
}
</td>
<td style={{ ...TD_BASE, fontSize: 13, color: 'var(--text-primary)' }}>{m.profile?.name || '—'}</td>
@@ -669,8 +669,8 @@ export default function ProjectDetailPage() {
<td style={{ padding: '5px 0', border: 'none', background: 'transparent' }}>
{p.avatar_url
? <div style={{ width: 26, height: 26, borderRadius: '50%', overflow: 'hidden' }}><img src={p.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: checked ? 'var(--accent)' : 'rgba(255,255,255,0.08)', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background 140ms' }}>
<span style={{ fontSize: 10, fontWeight: 600, color: checked ? '#000' : 'var(--text-primary)', lineHeight: 1 }}>{initials}</span>
: <div style={{ width: 26, height: 26, borderRadius: '50%', background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span style={{ fontSize: 10, fontWeight: 600, color: '#000', lineHeight: 1 }}>{initials}</span>
</div>
}
</td>
+25 -18
View File
@@ -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'}
</button>
@@ -416,7 +416,7 @@ export default function TaskDetail() {
<div style={{ fontSize: 28, fontWeight: 500, color: 'var(--text-primary)', lineHeight: 1.2 }}>{task.title}</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
{isTeamOrSub && status === 'not_started' && (
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={statusSaving} onClick={handleStart}>Start Task</button>
<button className="btn btn-outline" disabled={statusSaving} onClick={handleStart}>Start Task</button>
)}
{isTeamOrSub && status === 'in_progress' && task.assigned_to === currentUser?.id && (
<>
@@ -438,13 +438,13 @@ export default function TaskDetail() {
</>
)}
{isClient && ['client_approved', 'invoiced', 'paid'].includes(status) && (
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setRevisionModal(true)}>Request Revision</button>
<button className="btn btn-outline" onClick={() => setRevisionModal(true)}>Request Revision</button>
)}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
{submission?.service_type && (
<span className="badge badge-status" style={{ background: 'rgba(245,165,35,0.12)', color: 'var(--accent)', border: '1px solid rgba(245,165,35,0.25)', minWidth: 'unset' }}>{submission.service_type}</span>
<span className="badge badge-client">{submission.service_type}</span>
)}
<StatusBadge status={task.status || 'not_started'} />
</div>
@@ -529,7 +529,7 @@ export default function TaskDetail() {
</div>
{isClient && ['not_started', 'in_progress'].includes(status) && (
<div style={{ marginLeft: 'auto' }}>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setAmendModal(true)}>Amend Request</button>
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend Request</button>
</div>
)}
</div>
@@ -596,7 +596,7 @@ export default function TaskDetail() {
</div>
{isClient && i === 0 && ['not_started', 'in_progress'].includes(status) && (
<div style={{ marginLeft: 'auto' }}>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => setAmendModal(true)}>Amend</button>
<button className="btn btn-outline" onClick={() => setAmendModal(true)}>Amend</button>
</div>
)}
</div>
@@ -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 }}
/>
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment} style={{ fontSize: 11, padding: '0 12px', alignSelf: 'flex-end', height: 30 }}>Post</button>
<button className="btn btn-outline" disabled={commentSaving || !commentBody.trim()} onClick={handlePostComment}>Post</button>
</div>
{comments.length === 0
? <div style={{ color: 'var(--text-muted)', fontSize: 13 }}>No comments yet.</div>
@@ -649,7 +649,7 @@ export default function TaskDetail() {
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{dateStr} · {timeStr}</span>
</div>
{isOwn && (
<button className="btn btn-danger" onClick={() => handleDeleteComment(c.id)} style={{ fontSize: 11, padding: '0 10px', height: 26 }}>Delete</button>
<button className="btn btn-danger" onClick={() => handleDeleteComment(c.id)}>Delete</button>
)}
</div>
<div style={{ fontSize: 13, color: 'var(--text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap' }}>{c.body}</div>
@@ -659,12 +659,19 @@ export default function TaskDetail() {
}
</div>
)}
{activeTab === 'Folder' && company?.name && project?.name && (
<FileBrowser
initialPath={`/Clients/${safeName(company.name)}/Projects/${safeName(project.name)}/${safeName(task.title)}`}
rootPath={`/Clients/${safeName(company.name)}/Projects/${safeName(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 <FileBrowser initialPath={fbInitial} rootPath={fbRoot} />;
})()}
{activeTab === 'Folder' && (!company?.name || !project?.name) && (
<div style={{ color: 'var(--text-muted)' }}>No folder available task needs a project and company.</div>
)}
@@ -729,7 +736,7 @@ export default function TaskDetail() {
<div className="form-group">
<label style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)', display: 'block', marginBottom: 6 }}>Attach Files</label>
<input ref={revisionFileRef} type="file" multiple style={{ display: 'none' }} onChange={e => setRevisionFiles(prev => [...prev, ...Array.from(e.target.files)])} />
<button type="button" className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} onClick={() => revisionFileRef.current?.click()}>+ Add Files</button>
<button type="button" className="btn btn-outline" onClick={() => revisionFileRef.current?.click()}>+ Add Files</button>
{revisionFiles.length > 0 && (
<div style={{ marginTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{revisionFiles.map((f, i) => (
@@ -742,8 +749,8 @@ export default function TaskDetail() {
)}
</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px' }} disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
<button className="btn btn-outline" style={{ fontSize: 11, padding: '4px 12px', color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
<button className="btn btn-outline" disabled={revisionSaving} onClick={() => { setRevisionModal(false); setRevisionForm({ description: '', deadline: '' }); setRevisionFiles([]); }}>Cancel</button>
<button className="btn btn-outline" style={{ color: 'var(--accent)', borderColor: 'var(--accent)' }} disabled={revisionSaving || !revisionForm.description.trim()} onClick={handleSubmitRevision}>
{revisionSaving ? 'Submitting…' : 'Submit Revision'}
</button>
</div>
@@ -781,7 +788,7 @@ export default function TaskDetail() {
{amendFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '5px 10px', background: 'var(--card-bg-2)', borderRadius: 6, border: '1px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{f.name}</span>
<button type="button" className="btn btn-danger" onClick={() => setAmendFiles(prev => prev.filter((_, idx) => idx !== i))} style={{ fontSize: 10, padding: '1px 8px', height: 22 }}>Remove</button>
<button type="button" className="btn btn-danger" onClick={() => setAmendFiles(prev => prev.filter((_, idx) => idx !== i))}>Remove</button>
</div>
))}
</div>
+7 -10
View File
@@ -76,10 +76,9 @@ function ActionIcon({ actionKey, size = 27 }) {
}
function Avatar({ name, size = 27 }) {
const tone = iconTone(name);
return (
<div style={{ width: size, height: size, borderRadius: '50%', background: tone.bg, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill={tone.color}>
<div style={{ width: size, height: size, borderRadius: '50%', background: 'rgba(245,165,35,0.15)', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="#F5A523">
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4.4 3.6-7 8-7s8 2.6 8 7" />
</svg>
@@ -88,10 +87,9 @@ function Avatar({ name, size = 27 }) {
}
function InitialPortrait({ name }) {
const tone = iconTone(name);
return (
<div style={{ width: 27, height: 27, borderRadius: '50%', background: tone.bg, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: tone.color, lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: 'var(--accent)', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: '#000', lineHeight: 1 }}>{(name || '?')[0].toUpperCase()}</span>
</div>
);
}
@@ -592,18 +590,17 @@ function TeamPerformanceCard({ tasks, profiles }) {
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{people.slice(0, 5).map((p, i) => {
const pct = totalDone > 0 ? Math.round((p.done / totalDone) * 100) : 0;
const tone = iconTone(p.name);
return (
<div key={p.name} style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: i > 0 ? 10 : 0 }}>
{(() => { const prof = p.id ? profileMap.get(p.id) : null; const tone = iconTone(p.name); return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
{(() => { const prof = p.id ? profileMap.get(p.id) : null; return prof?.avatar_url ? <div style={{ width: 27, height: 27, borderRadius: '50%', flexShrink: 0, overflow: 'hidden' }}><img src={prof.avatar_url} alt={p.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /></div> : <Avatar name={p.name} />; })()}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{p.id ? <button type="button" className="dashboard-inline-link" style={{ fontSize: 13, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} onClick={() => navigate(`/profile/${p.id}`)}>{p.name}</button> : <span style={{ fontSize: 13, color: 'var(--text-primary)', fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>}
<span style={{ fontSize: 11, color: 'var(--text-muted)', flexShrink: 0, marginLeft: 8 }}>{p.newCount} new · {p.revCount} revision</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ flex: 1, height: 4, borderRadius: 2, background: tone.bg, overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: tone.color, width: `${pct}%`, transition: 'width 0.3s ease' }} />
<div style={{ flex: 1, height: 4, borderRadius: 2, background: 'rgba(245,165,35,0.15)', overflow: 'hidden' }}>
<div style={{ height: '100%', borderRadius: 2, background: 'var(--accent)', width: `${pct}%`, transition: 'width 0.3s ease' }} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-muted)', minWidth: 28, textAlign: 'right' }}>{pct}%</span>
</div>
+280 -26
View File
@@ -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 (
<Layout>
<div className="page-header" style={{ flexShrink: 0 }}>
<div className="page-header-left">
<DashboardBanner />
<div className="page-title dashboard-greeting">Finances</div>
<div className="page-subtitle">{invoices.length} invoice{invoices.length !== 1 ? 's' : ''} · {expenses.length} expense{expenses.length !== 1 ? 's' : ''} · {subcontractorPOs.length} subcontractor PO{subcontractorPOs.length !== 1 ? 's' : ''}</div>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* 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 }) => (
<div style={{ ...CARD, display: 'flex', alignItems: 'stretch', gap: 21, minHeight: 120 }}>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5 }}>{label}</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center' }}>
<div style={{ fontSize: 30, fontWeight: 400, color: 'var(--text-primary)', letterSpacing: -0.5, lineHeight: 1.1 }}>{value}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{paidYears.length > 0 && (
<select className="filter-select" value={exportYear} onChange={e => setExportYear(Number(e.target.value))} style={{ width: 120 }}>
{paidYears.map(y => <option key={y} value={y}>{y}</option>)}
{sub && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 5 }}>{sub}</div>}
</div>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', alignItems: 'flex-end', justifyContent: 'flex-start' }}>
<div style={{ width: 27, height: 27, borderRadius: '50%', background: iconBg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={iconColor} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">{iconSvg}</svg>
</div>
</div>
</div>
);
return (
<div style={{ display: 'grid', gridTemplateColumns: '3fr 2fr', gap: 24, flexShrink: 0 }}>
<div style={{ ...CARD }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>Revenue Overview</span>
<select className="filter-select" value={chartYear} onChange={e => setChartYear(Number(e.target.value))} style={{ width: 90, borderRadius: 8, fontSize: 11, fontWeight: 500 }}>
{(paidYears.length > 0 ? paidYears : [new Date().getFullYear()]).map(y => <option key={y} value={y}>{y}</option>)}
</select>
)}
<button className="btn btn-primary btn-sm" onClick={handleCPAExport} disabled={exporting}>
{exporting ? 'Exporting…' : 'Export for CPA'}
</button>
</div>
</div>
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '16px 16px 8px', marginBottom: 18 }}>
<ResponsiveContainer width="100%" height={200}>
<ResponsiveContainer width="100%" height={160}>
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="gradRevenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
<linearGradient id="gradProfit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
<linearGradient id="gradExpenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
<linearGradient id="gradOutstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Revenue" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#F5A523" stopOpacity={0.25}/><stop offset="95%" stopColor="#F5A523" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Profit" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#4ade80" stopOpacity={0.25}/><stop offset="95%" stopColor="#4ade80" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Expenses" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#ef4444" stopOpacity={0.2}/><stop offset="95%" stopColor="#ef4444" stopOpacity={0}/></linearGradient>
<linearGradient id="gc2Outstanding" x1="0" y1="0" x2="0" y2="1"><stop offset="5%" stopColor="#60a5fa" stopOpacity={0.2}/><stop offset="95%" stopColor="#60a5fa" stopOpacity={0}/></linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" vertical={false} />
<XAxis dataKey="month" tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'var(--text-muted)' }} axisLine={false} tickLine={false} tickFormatter={v => `$${v >= 1000 ? (v/1000).toFixed(0)+'k' : v}`} width={45} />
<Tooltip content={<FinancesChartTooltip year={chartYear} />} />
<Legend wrapperStyle={{ fontSize: 11, paddingTop: 8 }} />
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gradRevenue)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gradProfit)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gradExpenses)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gradOutstanding)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Revenue" stroke="#F5A523" strokeWidth={2} fill="url(#gc2Revenue)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Profit" stroke="#4ade80" strokeWidth={2} fill="url(#gc2Profit)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Expenses" stroke="#ef4444" strokeWidth={2} fill="url(#gc2Expenses)" dot={false} activeDot={{ r: 4 }} />
<Area type="monotone" dataKey="Outstanding" stroke="#60a5fa" strokeWidth={2} fill="url(#gc2Outstanding)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignContent: 'start' }}>
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} paid`} iconBg="rgba(245,165,35,0.15)" iconColor="#F5A523" iconSvg={<><line x1="12" y1="19" x2="12" y2="5"/><polyline points="5,12 12,5 19,12"/></>} />
<StatCard label="Outstanding" value={fmt(yo)} sub="sent invoices" iconBg="rgba(96,165,250,0.15)" iconColor="#60a5fa" iconSvg={<><circle cx="12" cy="12" r="8"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></>} />
<StatCard label="Expenses" value={fmt(ye)} sub="total spend" iconBg="rgba(239,68,68,0.15)" iconColor="#ef4444" iconSvg={<><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19,12 12,19 5,12"/></>} />
<StatCard label="Net Profit" value={fmt(yp)} sub="after expenses" iconBg="rgba(74,222,128,0.15)" iconColor="#4ade80" iconSvg={<><polyline points="4,13 9,18 20,7"/></>} />
</div>
</div>
);
})()}
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{[
{ id: 'overview', label: 'Overview' },
{ id: 'expenses', label: 'Expenses' },
{ id: 'subcontractors', label: 'Subcontractors' },
{ id: 'invoices', label: 'Invoices' },
{ id: 'legacy', label: 'Legacy' },
].map(t => (
<button
key={t.id}
type="button"
onClick={() => setFinanceTab(t.id)}
style={{
fontFamily: "Fourge, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
fontSize: 13, fontWeight: 500, letterSpacing: 0.2,
padding: '2px 0 5px', margin: '0 8px',
borderRadius: 0, border: 'none',
borderBottom: financeTab === t.id ? '2px solid var(--accent)' : '2px solid transparent',
cursor: 'pointer', background: 'transparent',
color: financeTab === t.id ? 'var(--text-primary)' : 'var(--text-secondary)',
transition: 'all 160ms',
}}
>{t.label}</button>
))}
{financeTab === 'expenses' && (
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
</div>
)}
</div>
<div />
</div>
{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 (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 24, flex: 1, minHeight: 0 }}>
{/* Recent Expenses */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>Recent Expenses</div>
{recentExp.length === 0 ? <div className="card-empty-center">No expenses</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup><col style={{ width: '45%' }}/><col style={{ width: '30%' }}/><col style={{ width: '25%' }}/></colgroup>
<thead><tr>
<SortTh col="description" sortKey={ovExpKey} sortDir={ovExpDir} onSort={ovExpToggle} style={TH}>Description</SortTh>
<SortTh col="category" sortKey={ovExpKey} sortDir={ovExpDir} onSort={ovExpToggle} style={{ ...TH, textAlign: 'center' }}>Category</SortTh>
<SortTh col="amount" sortKey={ovExpKey} sortDir={ovExpDir} onSort={ovExpToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead>
<tbody>{recentExp.map(e => (
<tr key={e.id}>
<td style={{ ...TD }}>{e.description || '—'}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>{e.category || '—'}</td>
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(e.amount)}</td>
</tr>
))}</tbody>
</table>
</div>
)}
</div>
{/* Recent Subcontractor Payments */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>Recent Subcontractor Payments</div>
{recentSub.length === 0 ? <div className="card-empty-center">No payments</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup><col style={{ width: '35%' }}/><col style={{ width: '35%' }}/><col style={{ width: '30%' }}/></colgroup>
<thead><tr>
<SortTh col="name" sortKey={ovSubKey} sortDir={ovSubDir} onSort={ovSubToggle} style={TH}>Name</SortTh>
<SortTh col="project" sortKey={ovSubKey} sortDir={ovSubDir} onSort={ovSubToggle} style={{ ...TH, textAlign: 'center' }}>Project</SortTh>
<SortTh col="amount" sortKey={ovSubKey} sortDir={ovSubDir} onSort={ovSubToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead>
<tbody>{recentSub.map(po => (
<tr key={po.id}>
<td style={{ ...TD }}>{po.profile?.name || '—'}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>{po.project?.name || '—'}</td>
<td style={{ ...TD, textAlign: 'right', color: '#F5A523' }}>{fmtAmt(po.amount)}</td>
</tr>
))}</tbody>
</table>
</div>
)}
</div>
{/* Recent Invoices */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>Recent Invoices</div>
{recentInv.length === 0 ? <div className="card-empty-center">No invoices</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup><col style={{ width: '40%' }}/><col style={{ width: '25%' }}/><col style={{ width: '35%' }}/></colgroup>
<thead><tr>
<SortTh col="company" sortKey={ovInvKey} sortDir={ovInvDir} onSort={ovInvToggle} style={TH}>Company</SortTh>
<SortTh col="invoice_date" sortKey={ovInvKey} sortDir={ovInvDir} onSort={ovInvToggle} style={{ ...TH, textAlign: 'center' }}>Date</SortTh>
<SortTh col="total" sortKey={ovInvKey} sortDir={ovInvDir} onSort={ovInvToggle} style={{ ...TH, textAlign: 'right' }}>Total</SortTh>
</tr></thead>
<tbody>{recentInv.map(inv => (
<tr key={inv.id}>
<td style={{ ...TD }}>{inv.company?.name || '—'}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>{fmtDate(inv.invoice_date)}</td>
<td style={{ ...TD, textAlign: 'right', color: '#4ade80' }}>{fmtAmt(inv.total)}</td>
</tr>
))}</tbody>
</table>
</div>
)}
</div>
</div>
);
})()}
{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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 0, flex: 1, minHeight: 0 }}>
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, flex: 1, minHeight: 0 }}>
{/* Left: expense list */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>Expenses</div>
{expenses.length === 0 ? <div className="card-empty-center">No expenses</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<table className="table-sticky-head" style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '14%' }} />
<col style={{ width: '34%' }} />
<col style={{ width: '16%' }} />
<col style={{ width: '24%' }} />
<col style={{ width: '12%' }} />
</colgroup>
<thead><tr>
<SortTh col="date" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Date</SortTh>
<SortTh col="description" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Description</SortTh>
<SortTh col="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'center' }}>Category</SortTh>
<SortTh col="notes" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>Notes</SortTh>
<SortTh col="amount" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
</tr></thead>
<tbody>{sortedExpenses.map(exp => (
<tr key={exp.id} style={{ cursor: 'pointer' }} onClick={() => startEditExpense(exp)}>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(exp.date)}</td>
<td style={{ ...TD }}>{exp.description || '—'}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)', textAlign: 'center' }}>{exp.category || '—'}</td>
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{exp.notes || '—'}</td>
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td>
</tr>
))}</tbody>
</table>
</div>
)}
</div>
{/* Right: category breakdown */}
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14, flexShrink: 0 }}>By Category</div>
{categoryTotals.length === 0 ? <div className="card-empty-center">No expenses</div> : (
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{categoryTotals.map(({ cat, total }) => {
const pct = grandTotal > 0 ? (total / grandTotal) * 100 : 0;
return (
<div key={cat}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
<span style={{ fontSize: 12, color: 'var(--text-primary)' }}>{cat}</span>
<span style={{ fontSize: 12, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(total)}</span>
</div>
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${pct}%`, background: '#ef4444', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
</div>
</div>
);
})}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 12, display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
<span style={{ fontSize: 12, fontWeight: 500, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: 0.6 }}>Total</span>
<span style={{ fontSize: 13, fontWeight: 500, color: '#ef4444', fontVariantNumeric: 'tabular-nums' }}>{fmtAmt(grandTotal)}</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
})()}
{financeTab === 'legacy' && <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: 16 }}>
{[
{ id: 'invoices', label: 'INVOICES' },
@@ -886,7 +1138,9 @@ export default function Invoices() {
</div>
</div>
)}
</div>}{/* end legacy wrapper */}
</div>{/* end flex column wrapper */}
</Layout>
);
}