@@ -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 } } >
{ /* Rece nt Expenses */ }
< div style = { { ... CARD , display : 'flex' , flexDirection : 'column' , minHeight : 0 } } >
< div style = { { fontSize : 11 , fontWeight : 500 , color : 'var(--text-secondary)' , letterSpacing : 0.8 , textTransform : 'uppercase ', marginBottom : 14 , flexShrink : 0 } } > Recent Expenses < / div >
{ recentExp . length === 0 ? < div className = "card-empty-center" > No expenses < / div > : (
< div className = "scrollbar-thin-theme table-scroll-hidden" style = { { flex : 1 , minHeight : 0 , overflowY : 'auto' } } >
< table className = "table-sticky-head" style = { { width : '100%' , borderCollapse : 'collapse' , tableLayout : 'fixed' } } >
< colgroup > < col style = { { width : '45%' } } / > < col style = { { width : '30%' } } / > < col style = { { width : '25%' } } / > < / colgroup >
< thead > < tr >
< SortTh col = "description" sortKey = { ovExpKey } sortDir = { ovExpDir } onSort = { ovExpToggle } style = { TH } > Description < / SortTh >
< SortTh col = "category " sortKey = { ovExpKey } sortDir = { ovExpDir } onSort = { ovExpToggle } style = { { ... TH , textAlign : 'center' } } > Category < / SortTh >
< SortTh col = "amount" sortKey = { ovExpKey } sortDir = { ovExpDir } onSort = { ovExpToggle } style = { { ... TH , textAlign : 'right' } } > Amount < / SortTh >
< / tr > < / thead >
< tbody > { recentExp . map ( e => (
< tr key = { e . id } >
< td style = { { ... TD } } > { e . description || '—' } < / td >
< td style = { { ... TD , fontSize : 12 , color : 'var(--text-muted)' , textAlign : 'center' } } > { e . category || '—' } < / td >
< td style = { { ... TD , textAlign : 'right' , color : '#ef4444' } } > { fmtAmt ( e . amount ) } < / td >
< / tr >
) ) } < / tbody >
< / table >
< / div >
) }
{ /* Card 1: This Mo nth 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 >
< / div >
) }
{ /* 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 >
< / div >
) }
{ /* 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,10 +835,6 @@ 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 >
) }
@@ -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 (
< 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 >
< / div >
< / div >
) ;
} ) ( ) }
{ financeTab === 'legacy' && < div >
< div style = { { display : 'flex' , alignItems : 'center' , gap : 0 , marginBottom : 16 } } >
{ [
@@ -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 >
) ;