feat: finance page overhaul — pie charts, month nav, tab improvements
- Overview tab: 3 donut pie charts (expenses by category, sub payments, invoiced) each with independent month navigation arrows - Expenses tab: big red total in category sidebar, left-aligned category column, white text - Subcontractors tab: big gold total in sidebar, pending/paid breakdown with gold highlight - Invoices tab: big green total in companies sidebar, dashboard-inline-link for invoice# and company, StatusBadge with Invoiced/Paid labels, column widths tuned - StatusBadge: add optional label prop for custom display text - Layout/style polish across tabs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -63,10 +63,13 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
## 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`
|
||||
- **`margin-bottom: 10` is the site-wide standard gap between any tab/control bar and the card(s) below — use this everywhere, no exceptions**
|
||||
- tab bar row must always be `min-height: var(--btn-height)` so the gap to the card never shifts when action buttons appear/disappear
|
||||
- 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
|
||||
- when the control bar uses a multi-column grid (to align with split-card layouts below), add `align-items: center` to the grid and `min-height: var(--btn-height)` to every column so row height is stable across all tab states
|
||||
|
||||
## 7) Dashboard Grids (Team)
|
||||
- Stat row: `grid-template-columns: 1fr 1fr 1fr 1.5fr`, `gap: 24`, `margin-bottom: 0`
|
||||
@@ -287,6 +290,8 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
|
||||
## 14.7) Status Tags
|
||||
- Shared status tags use `StatusBadge` (`.badge.badge-status`).
|
||||
- Rule: any workflow/state value (`not_started`, `in_progress`, `on_hold`, `client_review`, `client_approved`, `invoiced`, `paid`, `active`, `completed`, etc.) must render through `StatusBadge` only.
|
||||
- Do not use raw `span.badge badge-*` for statuses.
|
||||
- Base badge geometry:
|
||||
- `display: inline-flex`
|
||||
- `align-items: center`
|
||||
@@ -298,13 +303,19 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- `letter-spacing: 0.3px`
|
||||
- `white-space: nowrap`
|
||||
- Status badge geometry override (`.badge-status`):
|
||||
- `display: inline-flex`
|
||||
- `align-items: center`
|
||||
- `justify-content: center`
|
||||
- `min-width: 78px`
|
||||
- `height: 20px`
|
||||
- `justify-content: center`
|
||||
- `padding: 0 8px`
|
||||
- `font-size: 10px`
|
||||
- `line-height: 1`
|
||||
- `line-height: 1.05`
|
||||
- `box-sizing: border-box`
|
||||
- `padding-top: 1px` (optical vertical-centering correction for current font metrics)
|
||||
- Status colors are variant classes (`.badge-not_started`, `.badge-in_progress`, etc.) and are theme-aware.
|
||||
- Non-status chips (example: invoice line-item type `Initial`/`Revision`, or service-type labels) may use `.badge` variants directly.
|
||||
- When a non-status chip should visually align with status pills, add `.badge-status` to match geometry.
|
||||
- Exception: compact urgent tag (`.badge-needs_revision`) uses tighter geometry:
|
||||
- `min-width: 28px`
|
||||
- `padding: 3px 4px`
|
||||
@@ -332,6 +343,17 @@ This is the single source of truth for dashboard/profile visual structure and UI
|
||||
- `Pinned` + `Navigation` tree rows use text-only hover highlight (`color: var(--accent)`).
|
||||
- Do not apply row background fill for hover in this section.
|
||||
|
||||
## 17.5) Link Interaction Standard
|
||||
- Use shared link classes only; do not hand-roll page-specific link hover styles:
|
||||
- table/text links inside cards and tables: `table-link`
|
||||
- inline action links/buttons in cards/feeds: `dashboard-inline-link`
|
||||
- Hover behavior for both classes:
|
||||
- color changes to accent gold (`var(--accent)`)
|
||||
- no underline on hover/focus
|
||||
- cursor remains pointer
|
||||
- For table-heavy rows where hit targets are tight, row hover may also promote link color to accent; still keep text-only link treatment (no full-row fill just for links).
|
||||
- Do not rely on inline style color overrides for hover behavior; if a variant is needed, add/extend a shared class in `index.css`.
|
||||
|
||||
## 16) Non-Negotiable Implementation Rules
|
||||
- Keep gradient backgrounds on `html`, not `body`
|
||||
- Keep widget shell values (`18px 21px`, `8px`, blur 12, border token) consistent
|
||||
|
||||
@@ -18,10 +18,10 @@ const labels = {
|
||||
client: 'Client',
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status }) {
|
||||
export default function StatusBadge({ status, label }) {
|
||||
return (
|
||||
<span className={`badge badge-status badge-${status}`}>
|
||||
{labels[status] || status}
|
||||
{label ?? labels[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
+73
-7
@@ -1081,12 +1081,16 @@ textarea {
|
||||
/* Badges */
|
||||
.badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 4px; font-size: 11px; font-weight: 400; white-space: nowrap; letter-spacing: 0.3px; }
|
||||
.badge-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 78px;
|
||||
height: 20px;
|
||||
justify-content: center;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
line-height: 1.05;
|
||||
box-sizing: border-box;
|
||||
padding-top: 1px; /* optical center correction for Fourge font metrics */
|
||||
}
|
||||
.badge-not_started { background: #222; color: #888; border: 1px solid #333; }
|
||||
.badge-in_progress { background: rgba(37,99,235,0.15); color: #60a5fa; border: 1px solid rgba(37,99,235,0.3); }
|
||||
@@ -1138,14 +1142,29 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 400;
|
||||
transition: color 0.15s;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
.table-link:hover,
|
||||
.table-link:focus-visible {
|
||||
color: var(--accent) !important;
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
a.table-link:hover,
|
||||
span.table-link:hover,
|
||||
button.table-link:hover,
|
||||
a.table-link:focus-visible,
|
||||
span.table-link:focus-visible,
|
||||
button.table-link:focus-visible {
|
||||
color: var(--accent) !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
tbody tr:hover .table-link {
|
||||
color: var(--accent) !important;
|
||||
}
|
||||
|
||||
.table-sticky-head thead th {
|
||||
position: sticky;
|
||||
@@ -1207,13 +1226,22 @@ tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
text-align: inherit;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: color 0.15s;
|
||||
transition: color 0.15s, opacity 0.15s;
|
||||
}
|
||||
.dashboard-inline-link:hover,
|
||||
.dashboard-inline-link:focus-visible {
|
||||
color: var(--accent) !important;
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
button.dashboard-inline-link:hover,
|
||||
a.dashboard-inline-link:hover,
|
||||
button.dashboard-inline-link:focus-visible,
|
||||
a.dashboard-inline-link:focus-visible {
|
||||
color: var(--accent) !important;
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group { margin-bottom: 18px; }
|
||||
@@ -1668,6 +1696,38 @@ select option { background: #222; color: #fff; }
|
||||
.tab-btn:hover { border-color: var(--interactive-hover-border); color: var(--text-secondary); }
|
||||
.tab-btn.active { background: var(--accent); border-color: var(--accent); color: #000; font-weight: 400; }
|
||||
|
||||
/* Shared section tabs (Tasks / Projects / Finances / Detail tabs) */
|
||||
.section-tab-btn {
|
||||
font-family: 'Fourge', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.2px;
|
||||
padding: 2px 0 5px;
|
||||
margin: 0 8px;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
transition: all 160ms;
|
||||
}
|
||||
.section-tab-btn:hover,
|
||||
.section-tab-btn:focus-visible {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
outline: none;
|
||||
}
|
||||
button.section-tab-btn:hover,
|
||||
button.section-tab-btn:focus-visible {
|
||||
color: var(--accent) !important;
|
||||
border-bottom-color: var(--accent) !important;
|
||||
}
|
||||
.section-tab-btn.is-active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Rebuilt hover system (single source of truth) */
|
||||
.sidebar-link:hover {
|
||||
background-color: #1f1f1f;
|
||||
@@ -1682,6 +1742,14 @@ select option { background: #222; color: #fff; }
|
||||
.site-header-avatar-item:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
[data-theme="dark"] .site-header-avatar-item:hover {
|
||||
background-color: rgba(255,255,255,0.08);
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
}
|
||||
[data-theme="light"] .site-header-avatar-item:hover {
|
||||
background-color: rgba(0,0,0,0.06);
|
||||
border-color: rgba(0,0,0,0.1);
|
||||
}
|
||||
[data-theme="dark"] .sidebar-link:hover {
|
||||
background-color: #1f1f1f;
|
||||
border-color: rgba(255,255,255,0.08);
|
||||
@@ -1700,6 +1768,4 @@ select option { background: #222; color: #fff; }
|
||||
border-color: rgba(0,0,0,0.18);
|
||||
color: #0d0d0d;
|
||||
}
|
||||
[data-theme="light"] .site-header-avatar-item:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
[data-theme="light"] .site-header-avatar-item:hover { color: var(--accent); }
|
||||
|
||||
@@ -26,13 +26,6 @@ const MODAL_LBL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secon
|
||||
const TH_STYLE = { fontSize: 10, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.6, color: 'var(--text-muted)', textAlign: 'left', padding: '0 0 12px 5px', border: 'none', background: 'transparent', verticalAlign: 'top' };
|
||||
const TD_BASE = { padding: '5px', border: 'none', background: 'transparent', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' };
|
||||
const MODAL_IN = { fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' };
|
||||
const TAB_STYLE = (active) => ({
|
||||
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: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||
});
|
||||
|
||||
const emptyJobForm = () => ({ title: '', serviceType: '', deadline: addDaysToDateOnly(getTodayDateOnlyEST(), 3), description: '', requestedBy: '' });
|
||||
|
||||
@@ -366,7 +359,7 @@ export default function ProjectDetailPage() {
|
||||
const count = tabCounts[tab.id] ?? 0;
|
||||
const active = activeTab === tab.id;
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)} style={TAB_STYLE(active)}>
|
||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)} className={`section-tab-btn${active ? ' is-active' : ''}`}>
|
||||
{tab.label}
|
||||
{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: active ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
||||
</button>
|
||||
|
||||
@@ -62,13 +62,6 @@ const CARD = { padding: '18px 21px', borderRadius: 8 };
|
||||
const LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 14 };
|
||||
const META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 4 };
|
||||
const CARD_META_LABEL = { fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 2 };
|
||||
const TAB_STYLE = (active) => ({
|
||||
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: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||
});
|
||||
|
||||
const TABS = ['Overview', 'Revisions', 'Comments', 'Folder'];
|
||||
|
||||
@@ -444,7 +437,7 @@ export default function TaskDetail() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 20 }}>
|
||||
{submission?.service_type && (
|
||||
<span className="badge badge-client">{submission.service_type}</span>
|
||||
<span className="badge badge-status badge-client">{submission.service_type}</span>
|
||||
)}
|
||||
<StatusBadge status={task.status || 'not_started'} />
|
||||
</div>
|
||||
@@ -502,7 +495,7 @@ export default function TaskDetail() {
|
||||
{TABS.map(tab => {
|
||||
const count = tab === 'Revisions' ? revisions.length : tab === 'Comments' ? comments.length : 0;
|
||||
return (
|
||||
<button key={tab} onClick={() => setActiveTab(tab)} style={TAB_STYLE(activeTab === tab)}>
|
||||
<button key={tab} onClick={() => setActiveTab(tab)} className={`section-tab-btn${activeTab === tab ? ' is-active' : ''}`}>
|
||||
{tab}{count > 0 && <span style={{ marginLeft: 5, fontSize: 12, fontWeight: 600, color: activeTab === tab ? 'var(--accent)' : 'var(--text-muted)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 10, padding: '3px 8px' }}>{count}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
+4
-18
@@ -109,13 +109,6 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
if (s === 'cancelled' || s === 'archived') return 0;
|
||||
return 35;
|
||||
};
|
||||
const tabBtnStyle = (active) => ({
|
||||
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: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||
});
|
||||
return (
|
||||
<div style={{ minWidth: 0, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<TasksStatsRow tasks={statsTasks} />
|
||||
@@ -126,7 +119,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
|
||||
<div style={{ flex: '0 0 calc(30% - 24px)', minWidth: 0, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
||||
{projectTabs.map(t => (
|
||||
<button key={t.id} onClick={() => setProjTab(t.id)} style={tabBtnStyle(projTab === t.id)}>{t.label}</button>
|
||||
<button key={t.id} onClick={() => setProjTab(t.id)} className={`section-tab-btn${projTab === t.id ? ' is-active' : ''}`}>{t.label}</button>
|
||||
))}
|
||||
{onAddProject && (
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
@@ -521,13 +514,6 @@ export default function RequestsPage() {
|
||||
).values()];
|
||||
}, [isExternal, allRows, projects]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const tabBtnStyle = (active) => ({
|
||||
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: active ? '2px solid var(--accent)' : '2px solid transparent',
|
||||
cursor: 'pointer', background: 'transparent',
|
||||
color: active ? 'var(--text-primary)' : 'var(--text-secondary)', transition: 'all 160ms',
|
||||
});
|
||||
|
||||
const renderRow = (row) => {
|
||||
const project = projects.find(p => p.id === row.projectId);
|
||||
@@ -639,7 +625,7 @@ export default function RequestsPage() {
|
||||
{/* Controls bar: tabs left, filters + actions right */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 10, flexShrink: 0 }}>
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)} style={tabBtnStyle(activeTab === t.id)}>
|
||||
<button key={t.id} onClick={() => setActiveTab(t.id)} className={`section-tab-btn${activeTab === t.id ? ' is-active' : ''}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -650,7 +636,7 @@ export default function RequestsPage() {
|
||||
className="btn btn-outline"
|
||||
aria-label="Filter projects" title="Filter projects"
|
||||
onClick={() => setProjectFilterMenuOpen(o => !o)}
|
||||
style={{ width: 30, minWidth: 30, padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 4h12M4 8h8M6 12h4" />
|
||||
@@ -672,7 +658,7 @@ export default function RequestsPage() {
|
||||
className="btn btn-outline"
|
||||
aria-label="Filter companies" title="Filter companies"
|
||||
onClick={() => setCompanyFilterMenuOpen(o => !o)}
|
||||
style={{ width: 30, minWidth: 30, padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M2 4h12M4 8h8M6 12h4" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsi
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF } from '../../lib/invoice';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
@@ -137,7 +138,12 @@ export default function MyInvoices() {
|
||||
{inv.due_date ? new Date(inv.due_date).toLocaleDateString() : '—'}
|
||||
{isOverdue && <span style={{ marginLeft: 6, fontSize: 11 }}>Overdue</span>}
|
||||
</td>
|
||||
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
status={statusColor[inv.status] || 'not_started'}
|
||||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||
<td>
|
||||
<LoadingButton
|
||||
|
||||
+9
-6
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
@@ -115,9 +116,10 @@ export default function MyInvoiceDetail() {
|
||||
<div className="page-subtitle">Fourge Branding</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<span className={`badge badge-${STATUS_COLOR[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px' }}>
|
||||
{STATUS_LABEL[invoice.status]}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={STATUS_COLOR[invoice.status] || 'not_started'}
|
||||
label={STATUS_LABEL[invoice.status] || invoice.status}
|
||||
/>
|
||||
{invoice.status === 'paid' && (
|
||||
<LoadingButton
|
||||
className="btn btn-primary"
|
||||
@@ -159,9 +161,10 @@ export default function MyInvoiceDetail() {
|
||||
<div className="detail-item">
|
||||
<label>Status</label>
|
||||
<p>
|
||||
<span className={`badge badge-${STATUS_COLOR[invoice.status]}`}>
|
||||
{STATUS_LABEL[invoice.status]}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={STATUS_COLOR[invoice.status] || 'not_started'}
|
||||
label={STATUS_LABEL[invoice.status] || invoice.status}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
{invoice.submitted_at && (
|
||||
|
||||
+5
-3
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Layout from '../../components/Layout';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { withTimeout } from '../../lib/withTimeout';
|
||||
@@ -117,9 +118,10 @@ export default function MyPurchaseOrders() {
|
||||
{po.due_date ? ` · Due ${new Date(po.due_date).toLocaleDateString()}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`}>
|
||||
{poStatusLabel[po.status] || po.status}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={poStatusColor[po.status] || 'not_started'}
|
||||
label={poStatusLabel[po.status] || po.status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useParams, useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateInvoicePDF, generateReceiptPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
@@ -337,9 +338,10 @@ export default function InvoiceDetail() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<span className={`badge badge-${statusColor[invoice.status]}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
|
||||
{invoice.status}{isOverdue ? ' · Overdue' : ''}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
label={`${invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}${isOverdue ? ' · Overdue' : ''}`}
|
||||
/>
|
||||
<LoadingButton className="btn btn-primary" loading={generating === 'invoice'} disabled={Boolean(generating)} loadingText="Generating... PDF" onClick={handleDownload}>Download Invoice</LoadingButton>
|
||||
{invoice.status === 'sent' && (
|
||||
<button className="btn btn-outline" onClick={handleResendInvoice} disabled={saving}>Resend Invoice</button>
|
||||
|
||||
+458
-236
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { DashboardBanner } from '../../lib/dashboardBanner';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { useSortable } from '../../hooks/useSortable';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { readPageCache, writePageCache } from '../../lib/pageCache';
|
||||
@@ -29,6 +30,10 @@ const poStatusLabel = {
|
||||
paid: 'Paid',
|
||||
cancelled: 'Cancelled',
|
||||
};
|
||||
const invoiceStatusBadgeLabel = (status) => {
|
||||
if (status === 'sent') return 'Invoiced';
|
||||
return status ? status.charAt(0).toUpperCase() + status.slice(1) : '—';
|
||||
};
|
||||
const RECEIPT_BUCKET = 'expense-receipts';
|
||||
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 };
|
||||
@@ -80,6 +85,10 @@ export default function Invoices() {
|
||||
const { sortKey: ovSubKey, sortDir: ovSubDir, toggle: ovSubToggle } = useSortable('date');
|
||||
const { sortKey: ovInvKey, sortDir: ovInvDir, toggle: ovInvToggle } = useSortable('invoice_date');
|
||||
const [filterCompany, setFilterCompany] = useState('');
|
||||
const initMonth = () => { const n = new Date(); return { month: n.getMonth(), year: n.getFullYear() }; };
|
||||
const [ovMonth1, setOvMonth1] = useState(initMonth);
|
||||
const [ovMonth2, setOvMonth2] = useState(initMonth);
|
||||
const [ovMonth3, setOvMonth3] = useState(initMonth);
|
||||
const [exportYear, setExportYear] = useState(new Date().getFullYear());
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
@@ -557,17 +566,17 @@ export default function Invoices() {
|
||||
</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"/></>} />
|
||||
<StatCard label="Revenue" value={fmt(yr)} sub={`${chartYear} · paid invoices`} 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={`${chartYear} · sent & unpaid`} 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={`${chartYear} · 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={`${chartYear} · 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 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '7fr 3fr', gap: 24, marginTop: 24, marginBottom: 10, flexShrink: 0, alignItems: 'center' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, minHeight: 'var(--btn-height)' }}>
|
||||
{[
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'expenses', label: 'Expenses' },
|
||||
@@ -579,16 +588,7 @@ export default function Invoices() {
|
||||
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',
|
||||
}}
|
||||
className={`section-tab-btn${financeTab === t.id ? ' is-active' : ''}`}
|
||||
>{t.label}</button>
|
||||
))}
|
||||
{financeTab === 'expenses' && (
|
||||
@@ -596,6 +596,11 @@ export default function Invoices() {
|
||||
<button className="btn btn-outline" onClick={() => { setEditingExpenseId(''); setNewExpense(blankExpense()); setExpensesError(''); setShowExpenseForm(true); }}>+ Expense</button>
|
||||
</div>
|
||||
)}
|
||||
{financeTab === 'invoices' && (
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button className="btn btn-outline" onClick={() => navigate('/invoices/new')}>+ Invoice</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
@@ -610,95 +615,146 @@ export default function Invoices() {
|
||||
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 })}`;
|
||||
|
||||
const now = new Date();
|
||||
const curM = now.getMonth(), curY = now.getFullYear();
|
||||
const stepMonth = (p, dir) => dir === -1
|
||||
? (p.month === 0 ? { month: 11, year: p.year - 1 } : { month: p.month - 1, year: p.year })
|
||||
: (p.month === 11 ? { month: 0, year: p.year + 1 } : { month: p.month + 1, year: p.year });
|
||||
const MonthNav = ({ m, setter }) => {
|
||||
const atCurrent = m.month === curM && m.year === curY;
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, flexShrink: 0 }}>
|
||||
<button type="button" onClick={() => setter(p => stepMonth(p, -1))} style={{ background: 'none', border: 'none', padding: '0 6px', cursor: 'pointer', color: 'var(--text-secondary)', fontSize: 18, lineHeight: 1 }}>‹</button>
|
||||
<button type="button" onClick={() => setter(p => stepMonth(p, 1))} disabled={atCurrent} style={{ background: 'none', border: 'none', padding: '0 6px', cursor: atCurrent ? 'default' : 'pointer', color: 'var(--text-secondary)', fontSize: 18, lineHeight: 1, opacity: atCurrent ? 0.25 : 1 }}>›</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Card 1: Expenses
|
||||
const m1 = ovMonth1.month, y1 = ovMonth1.year;
|
||||
const thisMonthExp = expenses.filter(e => { const d = new Date(e.date); return d.getFullYear() === y1 && d.getMonth() === m1; });
|
||||
const thisMonthTotal = thisMonthExp.reduce((s, e) => s + Number(e.amount || 0), 0);
|
||||
const thisCatTotals = CATEGORIES.map(cat => ({ cat, total: thisMonthExp.filter(e => e.category === cat).reduce((s, e) => s + Number(e.amount || 0), 0) })).filter(c => c.total > 0).sort((a, b) => b.total - a.total).slice(0, 4);
|
||||
|
||||
// Card 2: Sub payments this month (paid sub invoices)
|
||||
const ovInvTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
const m2 = ovMonth2.month, y2 = ovMonth2.year;
|
||||
const pendingSubs = [...subInvoices.filter(i => { const d = new Date(i.paid_at || i.submitted_at); return d.getFullYear() === y2 && d.getMonth() === m2; })].sort((a, b) => new Date(b.paid_at || b.submitted_at) - new Date(a.paid_at || a.submitted_at));
|
||||
const pendingSubsTotal = pendingSubs.reduce((s, i) => s + ovInvTotal(i), 0);
|
||||
|
||||
// Card 3: All invoices issued this month
|
||||
const m3 = ovMonth3.month, y3 = ovMonth3.year;
|
||||
const monthInvoices = [...invoices.filter(i => { const d = new Date(i.invoice_date); return d.getFullYear() === y3 && d.getMonth() === m3; })];
|
||||
const outstandingTotal = monthInvoices.reduce((s, i) => s + Number(i.total || 0), 0);
|
||||
|
||||
const PIE_COLORS = ['#F5A523', '#4ade80', '#60a5fa', '#c084fc', '#f472b6', '#fb923c', '#34d399', '#a78bfa'];
|
||||
|
||||
// pie by subcontractor
|
||||
const subPieData = Object.values(pendingSubs.reduce((acc, inv) => {
|
||||
const name = inv.profile?.name || 'Unknown';
|
||||
if (!acc[name]) acc[name] = { name, value: 0 };
|
||||
acc[name].value += ovInvTotal(inv);
|
||||
return acc;
|
||||
}, {})).sort((a, b) => b.value - a.value);
|
||||
|
||||
// pie by company (outstanding invoices)
|
||||
const invPieData = Object.values(monthInvoices.reduce((acc, inv) => {
|
||||
const name = inv.company?.name || inv.bill_to || 'Unknown';
|
||||
if (!acc[name]) acc[name] = { name, value: 0 };
|
||||
acc[name].value += Number(inv.total || 0);
|
||||
return acc;
|
||||
}, {})).sort((a, b) => b.value - a.value);
|
||||
|
||||
const PieTooltipContent = ({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}>
|
||||
<div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div>
|
||||
<div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PieLegend = ({ data, colors }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 14 }}>
|
||||
{data.map((d, i) => (
|
||||
<div key={d.name} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, minWidth: 0 }}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: colors[i % colors.length], flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{d.name}</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(d.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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>
|
||||
{/* Card 1: This Month Expenses by category */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses</span>
|
||||
<MonthNav m={ovMonth1} setter={setOvMonth1} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(thisMonthTotal)}</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie data={thisCatTotals.length ? thisCatTotals : [{ cat: 'None', total: 1 }]} dataKey="total" nameKey="cat" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
|
||||
{thisCatTotals.length ? thisCatTotals.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
|
||||
</Pie>
|
||||
{thisCatTotals.length > 0 && <Tooltip content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||||
}} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{thisCatTotals.length > 0 && <PieLegend data={thisCatTotals.map(c => ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
|
||||
</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>
|
||||
|
||||
{/* Card 2: Pending Sub Payments by subcontractor */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments</span>
|
||||
<MonthNav m={ovMonth2} setter={setOvMonth2} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(pendingSubsTotal)}</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie data={subPieData.length ? subPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
|
||||
{subPieData.length ? subPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
|
||||
</Pie>
|
||||
{subPieData.length > 0 && <Tooltip content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||||
}} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{subPieData.length > 0 && <PieLegend data={subPieData} colors={PIE_COLORS} />}
|
||||
</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>
|
||||
|
||||
{/* Card 3: Invoiced this month by company */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase' }}>{MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced</span>
|
||||
<MonthNav m={ovMonth3} setter={setOvMonth3} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(outstandingTotal)}</div>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<PieChart>
|
||||
<Pie data={invPieData.length ? invPieData : [{ name: 'None', value: 1 }]} dataKey="value" nameKey="name" cx="50%" cy="50%" innerRadius={45} outerRadius={72} strokeWidth={0}>
|
||||
{invPieData.length ? invPieData.map((_, i) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />) : <Cell fill="var(--border)" />}
|
||||
</Pie>
|
||||
{invPieData.length > 0 && <Tooltip content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
return <div style={{ background: 'var(--card-bg)', border: '1px solid var(--border)', borderRadius: 4, padding: '8px 12px', fontSize: 12 }}><div style={{ color: payload[0].payload.fill, fontWeight: 500 }}>{payload[0].name}</div><div style={{ color: 'var(--text-primary)' }}>{fmtAmt(payload[0].value)}</div></div>;
|
||||
}} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
{invPieData.length > 0 && <PieLegend data={invPieData} colors={PIE_COLORS} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -717,7 +773,6 @@ export default function Invoices() {
|
||||
});
|
||||
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 (
|
||||
@@ -725,7 +780,6 @@ export default function Invoices() {
|
||||
<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' }}>
|
||||
@@ -739,16 +793,20 @@ export default function Invoices() {
|
||||
<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="category" sortKey={expSortKey} sortDir={expSortDir} onSort={expToggle} style={TH}>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>
|
||||
<tr key={exp.id}>
|
||||
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(exp.date)}</td>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => startEditExpense(exp)} style={{ fontSize: 13, fontWeight: 400 }}>
|
||||
{exp.description || '—'}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ ...TD, fontSize: 12 }}>{exp.category || '—'}</td>
|
||||
<td style={{ ...TD, fontSize: 12 }}>{exp.notes || '—'}</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: '#ef4444' }}>{fmtAmt(exp.amount)}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
@@ -758,7 +816,8 @@ export default function Invoices() {
|
||||
</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>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, color: 'var(--text-secondary)', letterSpacing: 0.8, textTransform: 'uppercase', marginBottom: 5, flexShrink: 0 }}>Category</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#ef4444', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</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 }}>
|
||||
@@ -776,11 +835,237 @@ export default function Invoices() {
|
||||
</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>
|
||||
);
|
||||
})()}
|
||||
|
||||
{financeTab === 'subcontractors' && (() => {
|
||||
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 subInvoiceStatusColor = { draft: 'not_started', submitted: 'in_progress', paid: 'client_approved' };
|
||||
const invTotal = inv => (inv.items || []).reduce((s, i) => s + Number(i.unit_price || 0) * Number(i.quantity || 1), 0);
|
||||
|
||||
const yearFiltered = subInvoices;
|
||||
|
||||
const sortedInvoices = subSort(yearFiltered, (inv, key) => {
|
||||
if (key === 'total') return invTotal(inv);
|
||||
if (key === 'subcontractor') return inv.profile?.name || '';
|
||||
if (key === 'submitted_at') return inv.submitted_at ? new Date(inv.submitted_at).getTime() : 0;
|
||||
if (key === 'paid_at') return inv.paid_at ? new Date(inv.paid_at).getTime() : 0;
|
||||
return inv[key] || '';
|
||||
});
|
||||
|
||||
const bySubcontractor = Object.values(yearFiltered.reduce((acc, inv) => {
|
||||
const key = inv.profile?.id || 'unknown';
|
||||
if (!acc[key]) acc[key] = { name: inv.profile?.name || 'Unknown', invoices: [] };
|
||||
acc[key].invoices.push(inv);
|
||||
return acc;
|
||||
}, {})).map(g => ({
|
||||
...g,
|
||||
total: g.invoices.reduce((s, i) => s + invTotal(i), 0),
|
||||
paid: g.invoices.filter(i => i.status === 'paid').reduce((s, i) => s + invTotal(i), 0),
|
||||
pending: g.invoices.filter(i => i.status === 'submitted').reduce((s, i) => s + invTotal(i), 0),
|
||||
})).sort((a, b) => b.total - a.total);
|
||||
|
||||
const grandTotal = bySubcontractor.reduce((s, g) => s + g.total, 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: invoice list */}
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{subInvoicesLoading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
||||
) : subInvoicesError ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--danger)' }}>{subInvoicesError}</div>
|
||||
) : yearFiltered.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: '14%' }} />
|
||||
<col style={{ width: '26%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '16%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
<col style={{ width: '14%' }} />
|
||||
</colgroup>
|
||||
<thead><tr>
|
||||
<SortTh col="invoice_number" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Invoice #</SortTh>
|
||||
<SortTh col="subcontractor" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Subcontractor</SortTh>
|
||||
<SortTh col="submitted_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Submitted</SortTh>
|
||||
<SortTh col="paid_at" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Paid</SortTh>
|
||||
<SortTh col="status" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={TH}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={subSortKey} sortDir={subSortDir} onSort={subToggle} style={{ ...TH, textAlign: 'right' }}>Amount</SortTh>
|
||||
</tr></thead>
|
||||
<tbody>{sortedInvoices.map(inv => {
|
||||
const total = invTotal(inv);
|
||||
return (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="table-link" onClick={() => navigate(`/sub-invoices/${inv.id}`)}>
|
||||
{inv.invoice_number || '—'}
|
||||
</button>
|
||||
</td>
|
||||
<td style={{ ...TD }}>{inv.profile?.name || '—'}</td>
|
||||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(inv.submitted_at)}</td>
|
||||
<td style={{ ...TD, fontSize: 12, color: 'var(--text-muted)' }}>{fmtDate(inv.paid_at)}</td>
|
||||
<td style={{ ...TD }}>
|
||||
<StatusBadge
|
||||
status={subInvoiceStatusColor[inv.status] || 'not_started'}
|
||||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'submitted' ? '#F5A523' : 'var(--text-primary)' }}>
|
||||
{fmtAmt(total)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Right: by subcontractor */}
|
||||
<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: 5, flexShrink: 0 }}>Subcontractors</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#F5A523', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||
{bySubcontractor.length === 0 ? <div className="card-empty-center">No data</div> : (
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{bySubcontractor.map(g => {
|
||||
const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0;
|
||||
return (
|
||||
<div key={g.name}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
||||
<span style={{ fontSize: 12, color: '#F5A523', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: '#F5A523', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.pending > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending</span> · {fmtAmt(g.paid)} paid</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{financeTab === 'invoices' && (() => {
|
||||
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 statusColor = { draft: 'not_started', sent: 'in_progress', paid: 'client_approved' };
|
||||
|
||||
const sorted = invSort(invoices, (inv, key) => {
|
||||
if (key === 'total') return Number(inv.total) || 0;
|
||||
if (key === 'invoice_date' || key === 'due_date') return new Date(inv[key]).getTime();
|
||||
if (key === 'company') return inv.company?.name || '';
|
||||
return inv[key] || '';
|
||||
});
|
||||
|
||||
const byCompany = Object.values(invoices.reduce((acc, inv) => {
|
||||
const name = inv.company?.name || inv.bill_to || 'Unknown';
|
||||
if (!acc[name]) acc[name] = { name, total: 0, paid: 0, outstanding: 0 };
|
||||
acc[name].total += Number(inv.total) || 0;
|
||||
if (inv.status === 'paid') acc[name].paid += Number(inv.total) || 0;
|
||||
else if (inv.status === 'sent') acc[name].outstanding += Number(inv.total) || 0;
|
||||
return acc;
|
||||
}, {})).sort((a, b) => b.total - a.total);
|
||||
|
||||
const grandTotal = byCompany.reduce((s, g) => s + g.total, 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 }}>
|
||||
<div style={{ ...CARD, display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||
{loading ? (
|
||||
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>Loading…</div>
|
||||
) : invoices.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: '20%' }} />
|
||||
<col style={{ width: '27%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '15%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
</colgroup>
|
||||
<thead><tr>
|
||||
<SortTh col="invoice_number" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Invoice #</SortTh>
|
||||
<SortTh col="company" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Company</SortTh>
|
||||
<SortTh col="invoice_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Date</SortTh>
|
||||
<SortTh col="due_date" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={TH}>Due</SortTh>
|
||||
<SortTh col="status" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'center' }}>Status</SortTh>
|
||||
<SortTh col="total" sortKey={invSortKey} sortDir={invSortDir} onSort={invToggle} style={{ ...TH, textAlign: 'right' }}>Total</SortTh>
|
||||
</tr></thead>
|
||||
<tbody>{sorted.map(inv => (
|
||||
<tr key={inv.id}>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => navigate(`/invoices/${inv.id}`)}>{inv.invoice_number}</button>
|
||||
</td>
|
||||
<td style={{ ...TD }}>
|
||||
<button type="button" className="dashboard-inline-link" onClick={() => inv.company?.id && navigate(`/company/${inv.company.id}`)}>{inv.company?.name || inv.bill_to || '—'}</button>
|
||||
</td>
|
||||
<td style={{ ...TD, fontSize: 12 }}>{fmtDate(inv.invoice_date)}</td>
|
||||
<td style={{ ...TD, fontSize: 12, color: inv.status !== 'paid' && new Date(inv.due_date) < new Date() ? 'var(--danger)' : 'var(--text-primary)' }}>{fmtDate(inv.due_date)}</td>
|
||||
<td style={{ ...TD, textAlign: 'center' }}>
|
||||
<StatusBadge
|
||||
status={statusColor[inv.status] || 'not_started'}
|
||||
label={invoiceStatusBadgeLabel(inv.status)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ ...TD, textAlign: 'right', color: inv.status === 'paid' ? '#4ade80' : inv.status === 'sent' ? '#F5A523' : 'var(--text-primary)' }}>
|
||||
{fmtAmt(inv.total)}
|
||||
</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<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: 5, flexShrink: 0 }}>Companies</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 400, letterSpacing: -0.5, lineHeight: 1.1, color: '#4ade80', fontVariantNumeric: 'tabular-nums', marginBottom: 14, flexShrink: 0 }}>{fmtAmt(grandTotal)}</div>
|
||||
{byCompany.length === 0 ? <div className="card-empty-center">No data</div> : (
|
||||
<div className="scrollbar-thin-theme table-scroll-hidden" style={{ flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{byCompany.map(g => {
|
||||
const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0;
|
||||
return (
|
||||
<div key={g.name}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 5 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '65%' }}>{g.name}</span>
|
||||
<span style={{ fontSize: 12, color: '#4ade80', fontVariantNumeric: 'tabular-nums', flexShrink: 0 }}>{fmtAmt(g.total)}</span>
|
||||
</div>
|
||||
<div style={{ height: 4, borderRadius: 2, background: 'var(--border)', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: '#4ade80', borderRadius: 2, opacity: 0.7, transition: 'width 400ms ease' }} />
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 3 }}><span style={{ color: g.outstanding > 0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding</span> · {fmtAmt(g.paid)} paid</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -877,7 +1162,12 @@ export default function Invoices() {
|
||||
{new Date(inv.due_date).toLocaleDateString()}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className={`badge badge-${statusColor[inv.status]}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<StatusBadge
|
||||
status={statusColor[inv.status] || 'not_started'}
|
||||
label={invoiceStatusBadgeLabel(inv.status)}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ fontWeight: 400, color: 'var(--accent)' }}>${Number(inv.total).toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -959,130 +1249,6 @@ export default function Invoices() {
|
||||
|
||||
</div>
|
||||
|
||||
{showExpenseForm && (
|
||||
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
|
||||
<div style={{ ...popupSurfaceStyle, padding: 28, width: '100%', maxWidth: 520, maxHeight: '90vh', overflowY: 'auto' }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 400, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)', marginBottom: 4 }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
|
||||
<div style={{ fontSize: 22, color: 'var(--text-primary)' }}>{editingExpenseId ? 'Edit expense' : 'Add an expense'}</div>
|
||||
</div>
|
||||
<button onClick={cancelExpenseEdit} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20, cursor: 'pointer', lineHeight: 1, padding: 4 }}>×</button>
|
||||
</div>
|
||||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="input"
|
||||
value={newExpense.date}
|
||||
onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))}
|
||||
style={FIELD_INPUT_STYLE}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Amount (USD)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="input"
|
||||
placeholder="0.00"
|
||||
value={newExpense.amount}
|
||||
onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))}
|
||||
style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 4, paddingLeft: 10 }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Description</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="What was this for?"
|
||||
value={newExpense.description}
|
||||
onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Category</label>
|
||||
<select
|
||||
className="input"
|
||||
value={newExpense.category}
|
||||
onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))}
|
||||
style={FIELD_INPUT_STYLE}
|
||||
>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Notes (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Any extra details"
|
||||
value={newExpense.notes}
|
||||
onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label style={FIELD_LABEL_STYLE}>Receipt / Photo (optional)</label>
|
||||
<input
|
||||
type="file"
|
||||
className="input"
|
||||
accept="image/*,.pdf"
|
||||
style={{ ...FIELD_INPUT_STYLE, padding: '9px 12px' }}
|
||||
onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))}
|
||||
/>
|
||||
{!newExpense.receipt && newExpense.receipt_path && (
|
||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt attached'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => handleViewReceipt(newExpense)}
|
||||
>
|
||||
View Receipt
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-sm"
|
||||
style={{ color: 'var(--danger)' }}
|
||||
onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}
|
||||
>
|
||||
Remove Receipt
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{newExpense.receipt && (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>
|
||||
{newExpense.receipt.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="action-buttons" style={{ marginTop: 4 }}>
|
||||
<button className="btn btn-primary btn-sm" type="submit" disabled={addingExpense}>
|
||||
{addingExpense ? (editingExpenseId ? 'Saving…' : 'Adding…') : (editingExpenseId ? 'Save Changes' : 'Add Expense')}
|
||||
</button>
|
||||
{editingExpenseId && (
|
||||
<button className="btn btn-outline btn-sm" type="button" onClick={cancelExpenseEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{expensesError && (
|
||||
<div style={{ fontSize: 12, color: 'var(--danger)' }}>
|
||||
{expensesError}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1125,7 +1291,12 @@ export default function Invoices() {
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: 12 }}>{inv.profile?.email || '—'}</div>
|
||||
</td>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'}</td>
|
||||
<td><span className={`badge badge-${subInvoiceStatusColor[inv.status] || 'not_started'}`} style={{ textTransform: 'capitalize' }}>{inv.status}</span></td>
|
||||
<td>
|
||||
<StatusBadge
|
||||
status={subInvoiceStatusColor[inv.status] || 'not_started'}
|
||||
label={inv.status ? inv.status.charAt(0).toUpperCase() + inv.status.slice(1) : '—'}
|
||||
/>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: 400, color: 'var(--accent)' }}>${total.toFixed(2)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
@@ -1140,6 +1311,57 @@ export default function Invoices() {
|
||||
)}
|
||||
</div>}{/* end legacy wrapper */}
|
||||
|
||||
{showExpenseForm && (
|
||||
<div style={popupOverlayStyle} onClick={cancelExpenseEdit}>
|
||||
<div style={{ ...popupSurfaceStyle, background: 'var(--card-bg)', width: 'min(520px, 96vw)', maxHeight: 'calc(100vh - 48px)', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }} onClick={e => e.stopPropagation()}>
|
||||
<div style={{ fontSize: 11, fontWeight: 500, textTransform: 'uppercase', letterSpacing: 0.8, color: 'var(--text-secondary)' }}>{editingExpenseId ? 'Edit Expense' : 'New Expense'}</div>
|
||||
<form onSubmit={handleAddExpense} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14 }}>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Date *</div>
|
||||
<input type="date" required value={newExpense.date} onChange={e => setNewExpense(p => ({ ...p, date: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Amount (USD) *</div>
|
||||
<input type="number" step="0.01" min="0" required placeholder="0.00" value={newExpense.amount} onChange={e => setNewExpense(p => ({ ...p, amount: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Description *</div>
|
||||
<input type="text" required placeholder="What was this for?" value={newExpense.description} onChange={e => setNewExpense(p => ({ ...p, description: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Category</div>
|
||||
<select value={newExpense.category} onChange={e => setNewExpense(p => ({ ...p, category: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }}>
|
||||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Notes</div>
|
||||
<input type="text" placeholder="Any extra details" value={newExpense.notes} onChange={e => setNewExpense(p => ({ ...p, notes: e.target.value }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={FIELD_LABEL_STYLE}>Receipt / Photo</div>
|
||||
<input type="file" accept="image/*,.pdf" onChange={e => setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} style={{ fontSize: 13, color: 'var(--text-primary)', background: 'var(--card-bg-2)', border: '1px solid var(--border)', borderRadius: 6, padding: '6px 10px', width: '100%', fontFamily: 'inherit', boxSizing: 'border-box' }} />
|
||||
{!newExpense.receipt && newExpense.receipt_path && (
|
||||
<div style={{ marginTop: 6, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt_name || 'Existing receipt attached'}</span>
|
||||
<button type="button" className="btn btn-outline" onClick={() => handleViewReceipt(newExpense)}>View</button>
|
||||
<button type="button" className="btn btn-outline" style={{ color: 'var(--danger)' }} onClick={() => setNewExpense(p => ({ ...p, removeReceipt: true, receipt_path: null, receipt_name: null }))}>Remove</button>
|
||||
</div>
|
||||
)}
|
||||
{newExpense.receipt && <div style={{ marginTop: 6, fontSize: 12, color: 'var(--text-muted)' }}>{newExpense.receipt.name}</div>}
|
||||
</div>
|
||||
{expensesError && <div style={{ fontSize: 12, color: 'var(--danger)' }}>{expensesError}</div>}
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||
<button type="submit" className="btn btn-outline" disabled={addingExpense}>{addingExpense ? 'Saving…' : 'Save'}</button>
|
||||
<button type="button" className="btn btn-outline" onClick={cancelExpenseEdit}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>{/* end flex column wrapper */}
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
import { blobToEmailAttachment, sendEmail } from '../../lib/email';
|
||||
@@ -121,9 +122,10 @@ export default function SubInvoiceDetail() {
|
||||
{invoice.profile?.name || 'External'} · {invoice.profile?.email || '—'}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge badge-${statusColor[invoice.status] || 'not_started'}`} style={{ fontSize: 13, padding: '6px 14px', textTransform: 'capitalize' }}>
|
||||
{invoice.status}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={statusColor[invoice.status] || 'not_started'}
|
||||
label={invoice.status ? invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1) : '—'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid-2" style={{ marginBottom: 24 }}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import Layout from '../../components/Layout';
|
||||
import LoadingButton from '../../components/LoadingButton';
|
||||
import SortTh from '../../components/SortTh';
|
||||
import StatusBadge from '../../components/StatusBadge';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { sendEmail } from '../../lib/email';
|
||||
import { generateSubcontractorPOPDF } from '../../lib/invoice';
|
||||
@@ -169,9 +170,10 @@ export default function SubcontractorPODetail() {
|
||||
<div className="page-title">{po.po_number || 'Purchase Order'}</div>
|
||||
</div>
|
||||
<div className="action-buttons">
|
||||
<span className={`badge badge-${poStatusColor[po.status] || 'not_started'}`} style={{ fontSize: 13, padding: '6px 14px' }}>
|
||||
{poStatusLabel[po.status] || po.status}
|
||||
</span>
|
||||
<StatusBadge
|
||||
status={poStatusColor[po.status] || 'not_started'}
|
||||
label={poStatusLabel[po.status] || po.status}
|
||||
/>
|
||||
<LoadingButton className="btn btn-primary" loading={generating} loadingText="Generating..." onClick={handleDownload}>Download PO</LoadingButton>
|
||||
{po.status !== 'draft' && po.status !== 'cancelled' && (
|
||||
<button className="btn btn-outline" onClick={handleResend} disabled={saving}>Resend PO</button>
|
||||
|
||||
Reference in New Issue
Block a user