From 986d831186f5a8b5b8fb02b7dc0272f688f7ed34 Mon Sep 17 00:00:00 2001 From: Krao Hasanee Date: Mon, 1 Jun 2026 20:14:14 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20finance=20page=20overhaul=20=E2=80=94?= =?UTF-8?q?=20pie=20charts,=20month=20nav,=20tab=20improvements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- layout.md | 26 +- src/components/StatusBadge.jsx | 4 +- src/index.css | 80 +- src/pages/ProjectDetail.jsx | 9 +- src/pages/TaskDetail.jsx | 11 +- src/pages/Tasks.jsx | 22 +- src/pages/client/ClientMyInvoices.jsx | 8 +- .../external/ExternalMyInvoiceDetail.jsx | 15 +- .../external/ExternalMyPurchaseOrders.jsx | 8 +- src/pages/team/TeamInvoiceDetail.jsx | 8 +- src/pages/team/TeamInvoices.jsx | 702 ++++++++++++------ src/pages/team/TeamSubInvoiceDetail.jsx | 8 +- src/pages/team/TeamSubcontractorPODetail.jsx | 8 +- 13 files changed, 604 insertions(+), 305 deletions(-) diff --git a/layout.md b/layout.md index ac60e7b..19741d0 100644 --- a/layout.md +++ b/layout.md @@ -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 diff --git a/src/components/StatusBadge.jsx b/src/components/StatusBadge.jsx index d7869f0..fb4406a 100644 --- a/src/components/StatusBadge.jsx +++ b/src/components/StatusBadge.jsx @@ -18,10 +18,10 @@ const labels = { client: 'Client', }; -export default function StatusBadge({ status }) { +export default function StatusBadge({ status, label }) { return ( - {labels[status] || status} + {label ?? labels[status] ?? status} ); } diff --git a/src/index.css b/src/index.css index 2561e4f..db0c01b 100644 --- a/src/index.css +++ b/src/index.css @@ -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); } diff --git a/src/pages/ProjectDetail.jsx b/src/pages/ProjectDetail.jsx index 756ec73..2971907 100644 --- a/src/pages/ProjectDetail.jsx +++ b/src/pages/ProjectDetail.jsx @@ -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 ( - diff --git a/src/pages/TaskDetail.jsx b/src/pages/TaskDetail.jsx index aa2ecca..18c651a 100644 --- a/src/pages/TaskDetail.jsx +++ b/src/pages/TaskDetail.jsx @@ -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() {
{submission?.service_type && ( - {submission.service_type} + {submission.service_type} )}
@@ -502,7 +495,7 @@ export default function TaskDetail() { {TABS.map(tab => { const count = tab === 'Revisions' ? revisions.length : tab === 'Comments' ? comments.length : 0; return ( - ); diff --git a/src/pages/Tasks.jsx b/src/pages/Tasks.jsx index 1a0ce4d..fd93ba9 100644 --- a/src/pages/Tasks.jsx +++ b/src/pages/Tasks.jsx @@ -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 (
@@ -126,7 +119,7 @@ function TasksPageShell({ statsTasks = [], projects = [], onAddProject = null, c
{projectTabs.map(t => ( - + ))} {onAddProject && (
@@ -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 */}
{tabs.map(t => ( - ))} @@ -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 }} > @@ -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 }} > diff --git a/src/pages/client/ClientMyInvoices.jsx b/src/pages/client/ClientMyInvoices.jsx index 6ba6b11..6c34c17 100644 --- a/src/pages/client/ClientMyInvoices.jsx +++ b/src/pages/client/ClientMyInvoices.jsx @@ -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 && Overdue} - {inv.status} + + + ${Number(inv.total).toFixed(2)} Fourge Branding
- - {STATUS_LABEL[invoice.status]} - + {invoice.status === 'paid' && (

- - {STATUS_LABEL[invoice.status]} - +

{invoice.submitted_at && ( diff --git a/src/pages/external/ExternalMyPurchaseOrders.jsx b/src/pages/external/ExternalMyPurchaseOrders.jsx index edda1b3..7aba092 100644 --- a/src/pages/external/ExternalMyPurchaseOrders.jsx +++ b/src/pages/external/ExternalMyPurchaseOrders.jsx @@ -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()}` : ''}
- - {poStatusLabel[po.status] || po.status} - +
diff --git a/src/pages/team/TeamInvoiceDetail.jsx b/src/pages/team/TeamInvoiceDetail.jsx index 39ecc2c..1b8b9da 100644 --- a/src/pages/team/TeamInvoiceDetail.jsx +++ b/src/pages/team/TeamInvoiceDetail.jsx @@ -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() {
- - {invoice.status}{isOverdue ? ' · Overdue' : ''} - + Download Invoice {invoice.status === 'sent' && ( diff --git a/src/pages/team/TeamInvoices.jsx b/src/pages/team/TeamInvoices.jsx index 9bd30df..c74c05a 100644 --- a/src/pages/team/TeamInvoices.jsx +++ b/src/pages/team/TeamInvoices.jsx @@ -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() {
- } /> - } /> - } /> - } /> + } /> + } /> + } /> + } />
); })()} -
-
+
+
{[ { 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} ))} {financeTab === 'expenses' && ( @@ -596,6 +596,11 @@ export default function Invoices() {
)} + {financeTab === 'invoices' && ( +
+ +
+ )}
@@ -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 ( +
+ + +
+ ); + }; + + // 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 ( +
+
{payload[0].name}
+
{fmtAmt(payload[0].value)}
+
+ ); + }; + + const PieLegend = ({ data, colors }) => ( +
+ {data.map((d, i) => ( +
+
+
+ {d.name} +
+ {fmtAmt(d.value)} +
+ ))} +
+ ); + return (
- {/* Recent Expenses */} -
-
Recent Expenses
- {recentExp.length === 0 ?
No expenses
: ( -
- - - - Description - Category - Amount - - {recentExp.map(e => ( - - - - - - ))} -
{e.description || '—'}{e.category || '—'}{fmtAmt(e.amount)}
-
- )} + {/* Card 1: This Month Expenses by category */} +
+
+ {MONTHS[m1]} {y1 !== curY ? y1 : ''} Expenses + +
+
{fmtAmt(thisMonthTotal)}
+ + + + {thisCatTotals.length ? thisCatTotals.map((_, i) => ) : } + + {thisCatTotals.length > 0 && { + if (!active || !payload?.length) return null; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + }} />} +
+
+ {thisCatTotals.length > 0 && ({ name: c.cat, value: c.total }))} colors={PIE_COLORS} />}
- {/* Recent Subcontractor Payments */} -
-
Recent Subcontractor Payments
- {recentSub.length === 0 ?
No payments
: ( -
- - - - Name - Project - Amount - - {recentSub.map(po => ( - - - - - - ))} -
{po.profile?.name || '—'}{po.project?.name || '—'}{fmtAmt(po.amount)}
-
- )} + + {/* Card 2: Pending Sub Payments by subcontractor */} +
+
+ {MONTHS[m2]} {y2 !== curY ? y2 : ''} Sub Payments + +
+
{fmtAmt(pendingSubsTotal)}
+ + + + {subPieData.length ? subPieData.map((_, i) => ) : } + + {subPieData.length > 0 && { + if (!active || !payload?.length) return null; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + }} />} +
+
+ {subPieData.length > 0 && }
- {/* Recent Invoices */} -
-
Recent Invoices
- {recentInv.length === 0 ?
No invoices
: ( -
- - - - Company - Date - Total - - {recentInv.map(inv => ( - - - - - - ))} -
{inv.company?.name || '—'}{fmtDate(inv.invoice_date)}{fmtAmt(inv.total)}
-
- )} + + {/* Card 3: Invoiced this month by company */} +
+
+ {MONTHS[m3]} {y3 !== curY ? y3 : ''} Invoiced + +
+
{fmtAmt(outstandingTotal)}
+ + + + {invPieData.length ? invPieData.map((_, i) => ) : } + + {invPieData.length > 0 && { + if (!active || !payload?.length) return null; + return
{payload[0].name}
{fmtAmt(payload[0].value)}
; + }} />} +
+
+ {invPieData.length > 0 && }
); @@ -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() {
{/* Left: expense list */}
-
Expenses
{expenses.length === 0 ?
No expenses
: (
@@ -739,16 +793,20 @@ export default function Invoices() { DateDescription - Category + CategoryNotesAmount{sortedExpenses.map(exp => ( - startEditExpense(exp)}> - - - - + + + + + ))} @@ -758,7 +816,8 @@ export default function Invoices() { {/* Right: category breakdown */}
-
By Category
+
Category
+
{fmtAmt(grandTotal)}
{categoryTotals.length === 0 ?
No expenses
: (
@@ -776,10 +835,6 @@ export default function Invoices() {
); })} -
- Total - {fmtAmt(grandTotal)} -
)} @@ -789,6 +844,236 @@ export default function Invoices() { ); })()} + {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 ( +
+
+ {/* Left: invoice list */} +
+ {subInvoicesLoading ? ( +
Loading…
+ ) : subInvoicesError ? ( +
{subInvoicesError}
+ ) : yearFiltered.length === 0 ? ( +
No invoices
+ ) : ( +
+
{fmtDate(exp.date)}{exp.description || '—'}{exp.category || '—'}{exp.notes || '—'}
{fmtDate(exp.date)} + + {exp.category || '—'}{exp.notes || '—'} {fmtAmt(exp.amount)}
+ + + + + + + + + + Invoice # + Subcontractor + Submitted + Paid + Status + Amount + + {sortedInvoices.map(inv => { + const total = invTotal(inv); + return ( + + + + + + + + + ); + })} +
+ + {inv.profile?.name || '—'}{fmtDate(inv.submitted_at)}{fmtDate(inv.paid_at)} + + + {fmtAmt(total)} +
+
+ )} +
+ {/* Right: by subcontractor */} +
+
Subcontractors
+
{fmtAmt(grandTotal)}
+ {bySubcontractor.length === 0 ?
No data
: ( +
+
+ {bySubcontractor.map(g => { + const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0; + return ( +
+
+ {g.name} + {fmtAmt(g.total)} +
+
+
+
+
0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.pending)} pending · {fmtAmt(g.paid)} paid
+
+ ); + })} +
+
+ )} +
+
+
+ ); + })()} + + {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 ( +
+
+
+ {loading ? ( +
Loading…
+ ) : invoices.length === 0 ? ( +
No invoices
+ ) : ( +
+ + + + + + + + + + + Invoice # + Company + Date + Due + Status + Total + + {sorted.map(inv => ( + + + + + + + + + ))} +
+ + + + {fmtDate(inv.invoice_date)}{fmtDate(inv.due_date)} + + + {fmtAmt(inv.total)} +
+
+ )} +
+
+
Companies
+
{fmtAmt(grandTotal)}
+ {byCompany.length === 0 ?
No data
: ( +
+
+ {byCompany.map(g => { + const pct = grandTotal > 0 ? (g.total / grandTotal) * 100 : 0; + return ( +
+
+ {g.name} + {fmtAmt(g.total)} +
+
+
+
+
0 ? '#F5A523' : 'var(--text-muted)' }}>{fmtAmt(g.outstanding)} outstanding · {fmtAmt(g.paid)} paid
+
+ ); + })} +
+
+ )} +
+
+
+ ); + })()} + {financeTab === 'legacy' &&
{[ @@ -877,7 +1162,12 @@ export default function Invoices() { {new Date(inv.due_date).toLocaleDateString()} - {inv.status} + + + ${Number(inv.total).toFixed(2)} ))} @@ -959,130 +1249,6 @@ export default function Invoices() {
- {showExpenseForm && ( -
-
e.stopPropagation()}> -
-
-
{editingExpenseId ? 'Edit Expense' : 'New Expense'}
-
{editingExpenseId ? 'Edit expense' : 'Add an expense'}
-
- -
-
-
-
- - setNewExpense(p => ({ ...p, date: e.target.value }))} - style={FIELD_INPUT_STYLE} - required - /> -
-
- - setNewExpense(p => ({ ...p, amount: e.target.value }))} - style={{ ...FIELD_INPUT_STYLE, minHeight: 38, borderRadius: 4, paddingLeft: 10 }} - required - /> -
-
-
- - setNewExpense(p => ({ ...p, description: e.target.value }))} - required - /> -
-
- - -
-
- - setNewExpense(p => ({ ...p, notes: e.target.value }))} - /> -
-
- - setNewExpense(p => ({ ...p, receipt: e.target.files?.[0] || null, removeReceipt: false }))} - /> - {!newExpense.receipt && newExpense.receipt_path && ( -
- {newExpense.receipt_name || 'Existing receipt attached'} - - -
- )} - {newExpense.receipt && ( -
- {newExpense.receipt.name} -
- )} -
-
- - {editingExpenseId && ( - - )} -
- {expensesError && ( -
- {expensesError} -
- )} -
-
-
- )}
)} @@ -1125,7 +1291,12 @@ export default function Invoices() {
{inv.profile?.email || '—'}
{inv.submitted_at ? new Date(inv.submitted_at).toLocaleDateString() : '—'} - {inv.status} + + + ${total.toFixed(2)} @@ -1140,6 +1311,57 @@ export default function Invoices() { )}
}{/* end legacy wrapper */} + {showExpenseForm && ( +
+
e.stopPropagation()}> +
{editingExpenseId ? 'Edit Expense' : 'New Expense'}
+
+
+
+
Date *
+ 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' }} /> +
+
+
Amount (USD) *
+ 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' }} /> +
+
+
+
Description *
+ 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' }} /> +
+
+
Category
+ +
+
+
Notes
+ 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' }} /> +
+
+
Receipt / Photo
+ 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 && ( +
+ {newExpense.receipt_name || 'Existing receipt attached'} + + +
+ )} + {newExpense.receipt &&
{newExpense.receipt.name}
} +
+ {expensesError &&
{expensesError}
} +
+ + +
+
+
+
+ )} +
{/* end flex column wrapper */} ); diff --git a/src/pages/team/TeamSubInvoiceDetail.jsx b/src/pages/team/TeamSubInvoiceDetail.jsx index 9d6b70f..e94e6d9 100644 --- a/src/pages/team/TeamSubInvoiceDetail.jsx +++ b/src/pages/team/TeamSubInvoiceDetail.jsx @@ -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 || '—'}
- - {invoice.status} - +
diff --git a/src/pages/team/TeamSubcontractorPODetail.jsx b/src/pages/team/TeamSubcontractorPODetail.jsx index 75ea798..09b8807 100644 --- a/src/pages/team/TeamSubcontractorPODetail.jsx +++ b/src/pages/team/TeamSubcontractorPODetail.jsx @@ -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() {
{po.po_number || 'Purchase Order'}
- - {poStatusLabel[po.status] || po.status} - + Download PO {po.status !== 'draft' && po.status !== 'cancelled' && (