@@ -0,0 +1,850 @@
'use client' ;
import { useMemo , useState , useCallback } from 'react' ;
import { useSearchParams } from 'next/navigation' ;
import { useQuery } from '@tanstack/react-query' ;
import {
Bar ,
BarChart ,
CartesianGrid ,
Cell ,
Line ,
LineChart ,
Pie ,
PieChart ,
ResponsiveContainer ,
Tooltip ,
XAxis ,
YAxis ,
} from 'recharts' ;
import { PageHeader } from '@/components/shared/page-header' ;
import { Card , CardContent , CardHeader , CardTitle } from '@/components/ui/card' ;
import { Button } from '@/components/ui/button' ;
import { Badge } from '@/components/ui/badge' ;
import { Skeleton } from '@/components/ui/skeleton' ;
import { DateRangePicker } from '@/components/dashboard/date-range-picker' ;
import { ReportExportButton } from '@/components/reports/shared/report-export-button' ;
import { ReportTemplatesButton } from '@/components/reports/shared/report-templates-button' ;
import { rangeToBounds , type DateRange } from '@/lib/analytics/range' ;
import { apiFetch } from '@/lib/api/client' ;
import { formatMoney , formatMoneyCompact , formatNumber } from '@/lib/reports/format-currency' ;
import type { ReportPayload } from '@/lib/reports/types' ;
// ─── Payload types (mirror the /api/v1/reports/financial response) ───────────
interface FinancialKpis {
revenueCollected : number ;
depositsCollected : number ;
balanceCollected : number ;
refundsIssued : number ;
pipelineExpected : number ;
expectedDepositsOutstanding : number ;
expensesTotal : number ;
netContribution : number ;
currency : string ;
}
interface RevenueByMonthRow {
month : string ;
deposit : number ;
balance : number ;
}
interface CollectionFunnelRow {
stage : string ;
label : string ;
count : number ;
}
interface AgingRow {
bucket : string ;
count : number ;
value : number ;
}
interface CashFlowRow {
month : string ;
inflow : number ;
outflow : number ;
}
interface ExpenseBreakdownRow {
category : string ;
total : number ;
}
interface OutstandingDepositRow {
interestId : string ;
clientName : string ;
mooring : string | null ;
expected : number ;
collected : number ;
remaining : number ;
currency : string ;
daysOutstanding : number ;
}
interface RecentPaymentRow {
id : string ;
receivedAt : string ;
clientName : string ;
mooring : string | null ;
paymentType : string ;
amount : number ;
currency : string ;
}
interface RefundRow {
id : string ;
receivedAt : string ;
clientName : string ;
amount : number ;
currency : string ;
notes : string | null ;
}
interface ExpenseLedgerRow {
id : string ;
expenseDate : string ;
payer : string | null ;
category : string | null ;
establishmentName : string | null ;
amount : number ;
currency : string ;
paymentStatus : string | null ;
}
interface FinancialPayload {
data : {
kpis : FinancialKpis ;
revenueByMonth : RevenueByMonthRow [ ] ;
collectionFunnel : CollectionFunnelRow [ ] ;
aging : AgingRow [ ] ;
cashFlow : CashFlowRow [ ] ;
expenseBreakdown : ExpenseBreakdownRow [ ] ;
outstandingDeposits : OutstandingDepositRow [ ] ;
recentPayments : RecentPaymentRow [ ] ;
refundLog : RefundRow [ ] ;
expenseLedger : ExpenseLedgerRow [ ] ;
range : { from : string ; to : string } ;
} ;
}
interface FinancialTemplateConfig extends Record < string , unknown > {
kind : 'financial' ;
range : DateRange ;
}
type MonthGranularity = 'month' | 'quarter' | 'year' ;
const DONUT_COLORS = [
'hsl(var(--chart-1))' ,
'hsl(var(--chart-3))' ,
'hsl(var(--chart-4))' ,
'hsl(var(--chart-5))' ,
'hsl(var(--chart-2))' ,
'hsl(var(--chart-6))' ,
] ;
export function FinancialReportClient ( { portSlug : _portSlug } : { portSlug : string } ) {
const searchParams = useSearchParams ( ) ;
const initialTemplateId = searchParams ? . get ( 'templateId' ) ? ? null ;
const [ range , setRange ] = useState < DateRange > ( '1y' ) ;
const [ granularity , setGranularity ] = useState < MonthGranularity > ( 'month' ) ;
const [ activeTemplateId , setActiveTemplateId ] = useState < string | null > ( initialTemplateId ) ;
const handleRangeChange = useCallback ( ( next : DateRange ) = > {
setRange ( next ) ;
setActiveTemplateId ( null ) ;
} , [ ] ) ;
const currentConfig : FinancialTemplateConfig = useMemo (
( ) = > ( { kind : 'financial' , range } ) ,
[ range ] ,
) ;
const handleApplyTemplate = useCallback ( ( config : FinancialTemplateConfig ) = > {
if ( config . range ) setRange ( config . range ) ;
} , [ ] ) ;
const bounds = useMemo ( ( ) = > rangeToBounds ( range ) , [ range ] ) ;
const query = useQuery < FinancialPayload > ( {
queryKey : [ 'reports' , 'financial' , bounds . from . toISOString ( ) , bounds . to . toISOString ( ) ] ,
queryFn : ( ) = >
apiFetch < FinancialPayload > (
` /api/v1/reports/financial?from= ${ encodeURIComponent ( bounds . from . toISOString ( ) ) } &to= ${ encodeURIComponent ( bounds . to . toISOString ( ) ) } ` ,
) ,
staleTime : 30_000 ,
} ) ;
const d = query . data ? . data ;
const kpis = d ? . kpis ;
const currency = kpis ? . currency ? ? 'EUR' ;
const revenueByMonth = d ? . revenueByMonth ? ? [ ] ;
const collectionFunnel = d ? . collectionFunnel ? ? [ ] ;
const aging = d ? . aging ? ? [ ] ;
const expenseBreakdown = d ? . expenseBreakdown ? ? [ ] ;
const outstandingDeposits = d ? . outstandingDeposits ? ? [ ] ;
const recentPayments = d ? . recentPayments ? ? [ ] ;
const refundLog = d ? . refundLog ? ? [ ] ;
const expenseLedger = d ? . expenseLedger ? ? [ ] ;
// Re-bucket the monthly revenue series for the quarter/year toggle.
// Depend on the query-data reference (stable across renders once
// loaded) rather than the `?? []` fallback, which would be a fresh
// array each render.
const revenueSeries = useMemo (
( ) = > rebucketRevenue ( d ? . revenueByMonth ? ? [ ] , granularity ) ,
[ d ? . revenueByMonth , granularity ] ,
) ;
const cashFlowSeries = useMemo (
( ) = > ( d ? . cashFlow ? ? [ ] ) . map ( ( r ) = > ( { . . . r , label : formatMonthLabel ( r . month ) } ) ) ,
[ d ? . cashFlow ] ,
) ;
const fundedCount = collectionFunnel . length > 0 ? collectionFunnel [ 0 ] ! . count : 0 ;
function buildExportPayload ( ) : ReportPayload {
if ( ! kpis ) throw new Error ( 'Report still loading' ) ;
return {
title : 'Financial' ,
description : 'Revenue collected, deposits, outstanding, cash flow, and expenses.' ,
filenameSlug : 'financial' ,
range : bounds ,
kpis : [
{ label : 'Revenue collected' , value : formatMoney ( kpis . revenueCollected , currency ) } ,
{ label : 'Deposits collected' , value : formatMoney ( kpis . depositsCollected , currency ) } ,
{ label : 'Balance collected' , value : formatMoney ( kpis . balanceCollected , currency ) } ,
{
label : 'Pipeline (expected deposits)' ,
value : formatMoney ( kpis . pipelineExpected , currency ) ,
} ,
{
label : 'Outstanding deposits' ,
value : formatMoney ( kpis . expectedDepositsOutstanding , currency ) ,
} ,
{ label : 'Expenses' , value : formatMoney ( kpis . expensesTotal , currency ) } ,
{ label : 'Net contribution' , value : formatMoney ( kpis . netContribution , currency ) } ,
] ,
sections : [
{
title : 'Revenue by month' ,
columns : [
{ key : 'month' , label : 'Month' } ,
{ key : 'deposit' , label : 'Deposits' , align : 'right' } ,
{ key : 'balance' , label : 'Balance' , align : 'right' } ,
] ,
rows : revenueByMonth.map ( ( r ) = > ( {
month : r.month ,
deposit : formatMoney ( r . deposit , currency ) ,
balance : formatMoney ( r . balance , currency ) ,
} ) ) ,
} ,
{
title : 'Outstanding deposits' ,
columns : [
{ key : 'clientName' , label : 'Client' } ,
{ key : 'mooring' , label : 'Berth' } ,
{ key : 'remaining' , label : 'Remaining' , align : 'right' } ,
{ key : 'daysOutstanding' , label : 'Age (days)' , align : 'right' } ,
] ,
rows : outstandingDeposits.map ( ( r ) = > ( {
clientName : r.clientName ,
mooring : r.mooring ? ? '—' ,
remaining : formatMoney ( r . remaining , r . currency ) ,
daysOutstanding : r.daysOutstanding ,
} ) ) ,
} ,
{
title : 'Expense ledger' ,
columns : [
{ key : 'expenseDate' , label : 'Date' } ,
{ key : 'category' , label : 'Category' } ,
{ key : 'payer' , label : 'Payer' } ,
{ key : 'amount' , label : 'Amount' , align : 'right' } ,
{ key : 'paymentStatus' , label : 'Status' } ,
] ,
rows : expenseLedger.map ( ( r ) = > ( {
expenseDate : r.expenseDate ? r . expenseDate . slice ( 0 , 10 ) : '—' ,
category : r.category ? ? '—' ,
payer : r.payer ? ? '—' ,
amount : formatMoney ( r . amount , r . currency ) ,
paymentStatus : r.paymentStatus ? ? '—' ,
} ) ) ,
} ,
] ,
} ;
}
const isLoading = query . isLoading || ! kpis ;
return (
< div className = "space-y-6" >
< PageHeader
eyebrow = "Reports"
title = "Financial"
description = "Revenue collected, deposits, outstanding balances, cash flow, and expenses. Sourced from recorded payments; the CRM does not invoice."
actions = {
< div className = "flex items-center gap-2" >
< DateRangePicker value = { range } onChange = { handleRangeChange } / >
< ReportTemplatesButton < FinancialTemplateConfig >
kind = "financial"
currentConfig = { currentConfig }
onApply = { handleApplyTemplate }
activeTemplateId = { activeTemplateId }
onActiveTemplateChange = { setActiveTemplateId }
initialTemplateId = { initialTemplateId }
/ >
< ReportExportButton buildPayload = { buildExportPayload } disabled = { ! kpis } / >
< / div >
}
/ >
{ /* KPI STRIP — 7 tiles */ }
< section
aria-label = "Financial KPIs"
className = "grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"
>
{ isLoading ? (
Array . from ( { length : 7 } ) . map ( ( _ , i ) = > < KpiSkeleton key = { i } / > )
) : (
< >
< KpiCard
label = "Revenue collected"
value = { formatMoney ( kpis . revenueCollected , currency ) }
hint = "All payments received in period (net of refunds)"
/ >
< KpiCard
label = "Deposits collected"
value = { formatMoney ( kpis . depositsCollected , currency ) }
/ >
< KpiCard
label = "Balance collected"
value = { formatMoney ( kpis . balanceCollected , currency ) }
/ >
< KpiCard
label = "Pipeline (expected)"
value = { formatMoney ( kpis . pipelineExpected , currency ) }
hint = "Expected deposits across open deals"
/ >
< KpiCard
label = "Outstanding deposits"
value = { formatMoney ( kpis . expectedDepositsOutstanding , currency ) }
hint = "Expected but not yet collected"
/ >
< KpiCard
label = "Expenses"
value = { formatMoney ( kpis . expensesTotal , currency ) }
hint = {
kpis . refundsIssued > 0
? ` ${ formatMoney ( kpis . refundsIssued , currency ) } refunded `
: undefined
}
/ >
< KpiCard
label = "Net contribution"
value = { formatMoney ( kpis . netContribution , currency ) }
valueTone = { kpis . netContribution >= 0 ? 'positive' : 'negative' }
hint = "Revenue − expenses"
/ >
< / >
) }
< / section >
{ /* CHART 1 — Revenue by month (stacked: deposit / balance) */ }
< Card >
< CardHeader className = "flex-row items-start justify-between gap-2 space-y-0" >
< div >
< CardTitle className = "text-base" > Revenue collected over time < / CardTitle >
< p className = "text-xs text-muted-foreground" >
Payments received per period , split deposits vs balance .
< / p >
< / div >
< div className = "flex gap-1" >
{ ( [ 'month' , 'quarter' , 'year' ] as MonthGranularity [ ] ) . map ( ( g ) = > (
< Button
key = { g }
type = "button"
size = "sm"
variant = { granularity === g ? 'default' : 'outline' }
className = "h-7 px-2 text-xs capitalize"
onClick = { ( ) = > setGranularity ( g ) }
>
{ g }
< / Button >
) ) }
< / div >
< / CardHeader >
< CardContent >
{ isLoading ? (
< Skeleton className = "h-[300px] w-full" / >
) : revenueSeries . every ( ( r ) = > r . deposit === 0 && r . balance === 0 ) ? (
< EmptyState >
No payments recorded in this period . Revenue appears here as deposits and balances are
received on the Payments tab .
< / EmptyState >
) : (
< ResponsiveContainer width = "100%" height = { 300 } >
< BarChart data = { revenueSeries } margin = { { top : 8 , right : 8 , left : 4 , bottom : 8 } } >
< CartesianGrid strokeDasharray = "3 3" className = "stroke-border" vertical = { false } / >
< XAxis
dataKey = "label"
tick = { { fontSize : 11 , fill : 'hsl(var(--muted-foreground))' } }
interval = "preserveStartEnd"
/ >
< YAxis
tickFormatter = { ( v ) = > formatMoneyCompact ( Number ( v ) , currency ) }
tick = { { fontSize : 11 , fill : 'hsl(var(--muted-foreground))' } }
width = { 64 }
/ >
< Tooltip
contentStyle = { tooltipStyle }
formatter = { ( value , name ) = > [
formatMoney ( Number ( value ) , currency ) ,
name === 'deposit' ? 'Deposits' : 'Balance' ,
] }
/ >
< Bar
dataKey = "deposit"
stackId = "rev"
fill = "hsl(var(--chart-1))"
radius = { [ 0 , 0 , 0 , 0 ] }
/ >
< Bar
dataKey = "balance"
stackId = "rev"
fill = "hsl(var(--chart-3))"
radius = { [ 3 , 3 , 0 , 0 ] }
/ >
< / BarChart >
< / ResponsiveContainer >
) }
< / CardContent >
< / Card >
{ /* CHART 2 + 3 — Collection funnel + AR aging side by side */ }
< div className = "grid gap-4 lg:grid-cols-2" >
< Card >
< CardHeader >
< CardTitle className = "text-base" > Collection funnel < / CardTitle >
< p className = "text-xs text-muted-foreground" >
Deals reaching each money milestone in the period . Highlights where revenue leaks .
< / p >
< / CardHeader >
< CardContent >
{ isLoading ? (
< Skeleton className = "h-[220px] w-full" / >
) : (
< div className = "space-y-2 py-2" >
{ collectionFunnel . map ( ( row ) = > {
const pct = fundedCount > 0 ? ( row . count / fundedCount ) * 100 : 0 ;
return (
< div key = { row . stage } className = "space-y-1" >
< div className = "flex items-center justify-between text-xs" >
< span className = "font-medium text-foreground" > { row . label } < / span >
< span className = "tabular-nums text-muted-foreground" >
{ formatNumber ( row . count ) }
< / span >
< / div >
< div className = "h-7 w-full rounded bg-muted/50" >
< div
className = "flex h-7 items-center rounded bg-[hsl(var(--chart-1))] px-2"
style = { { width : ` ${ Math . max ( pct , row . count > 0 ? 8 : 0 ) } % ` } }
/ >
< / div >
< / div >
) ;
} ) }
< / div >
) }
< / CardContent >
< / Card >
< Card >
< CardHeader >
< CardTitle className = "text-base" > Outstanding deposits by age < / CardTitle >
< p className = "text-xs text-muted-foreground" >
Expected deposits not yet collected , bucketed by how long the deal has been open .
< / p >
< / CardHeader >
< CardContent >
{ isLoading ? (
< Skeleton className = "h-[220px] w-full" / >
) : aging . every ( ( r ) = > r . value === 0 ) ? (
< EmptyState > No outstanding deposits . Every open deal is paid up . < / EmptyState >
) : (
< ResponsiveContainer width = "100%" height = { 220 } >
< BarChart
data = { aging }
layout = "vertical"
margin = { { top : 4 , right : 12 , left : 8 , bottom : 4 } }
>
< CartesianGrid
strokeDasharray = "3 3"
className = "stroke-border"
horizontal = { false }
/ >
< XAxis
type = "number"
tickFormatter = { ( v ) = > formatMoneyCompact ( Number ( v ) , currency ) }
tick = { { fontSize : 11 , fill : 'hsl(var(--muted-foreground))' } }
/ >
< YAxis
type = "category"
dataKey = "bucket"
tick = { { fontSize : 11 , fill : 'hsl(var(--muted-foreground))' } }
width = { 48 }
/ >
< Tooltip
contentStyle = { tooltipStyle }
formatter = { ( value , _name , item ) = > [
` ${ formatMoney ( Number ( value ) , currency ) } · ${ item ? . payload ? . count ? ? 0 } deal(s) ` ,
'Outstanding' ,
] }
/ >
< Bar dataKey = "value" fill = "hsl(var(--chart-4))" radius = { [ 0 , 3 , 3 , 0 ] } / >
< / BarChart >
< / ResponsiveContainer >
) }
< / CardContent >
< / Card >
< / div >
{ /* CHART 4 — Cash flow (inflow vs outflow) */ }
< Card >
< CardHeader >
< CardTitle className = "text-base" > Cash flow < / CardTitle >
< p className = "text-xs text-muted-foreground" >
Money in ( payments received ) vs money out ( expenses booked ) , per month .
< / p >
< / CardHeader >
< CardContent >
{ isLoading ? (
< Skeleton className = "h-[280px] w-full" / >
) : (
< ResponsiveContainer width = "100%" height = { 280 } >
< LineChart data = { cashFlowSeries } margin = { { top : 8 , right : 8 , left : 4 , bottom : 8 } } >
< CartesianGrid strokeDasharray = "3 3" className = "stroke-border" / >
< XAxis
dataKey = "label"
tick = { { fontSize : 11 , fill : 'hsl(var(--muted-foreground))' } }
interval = "preserveStartEnd"
/ >
< YAxis
tickFormatter = { ( v ) = > formatMoneyCompact ( Number ( v ) , currency ) }
tick = { { fontSize : 11 , fill : 'hsl(var(--muted-foreground))' } }
width = { 64 }
/ >
< Tooltip
contentStyle = { tooltipStyle }
formatter = { ( value , name ) = > [
formatMoney ( Number ( value ) , currency ) ,
name === 'inflow' ? 'Inflow' : 'Outflow' ,
] }
/ >
< Line
type = "monotone"
dataKey = "inflow"
stroke = "hsl(var(--chart-1))"
strokeWidth = { 2 }
dot = { false }
/ >
< Line
type = "monotone"
dataKey = "outflow"
stroke = "hsl(var(--chart-4))"
strokeWidth = { 2 }
dot = { false }
/ >
< / LineChart >
< / ResponsiveContainer >
) }
< / CardContent >
< / Card >
{ /* CHART 5 — Expense breakdown donut + Recent payments table */ }
< div className = "grid gap-4 lg:grid-cols-2" >
< Card >
< CardHeader >
< CardTitle className = "text-base" > Expense breakdown < / CardTitle >
< p className = "text-xs text-muted-foreground" > By category , for the selected period . < / p >
< / CardHeader >
< CardContent >
{ isLoading ? (
< Skeleton className = "h-[260px] w-full" / >
) : expenseBreakdown . length === 0 ? (
< EmptyState > No expenses booked in this period . < / EmptyState >
) : (
< ResponsiveContainer width = "100%" height = { 260 } >
< PieChart >
< Pie
data = { expenseBreakdown }
dataKey = "total"
nameKey = "category"
cx = "50%"
cy = "50%"
innerRadius = { 55 }
outerRadius = { 90 }
paddingAngle = { 2 }
>
{ expenseBreakdown . map ( ( _ , i ) = > (
< Cell key = { i } fill = { DONUT_COLORS [ i % DONUT_COLORS . length ] } / >
) ) }
< / Pie >
< Tooltip
contentStyle = { tooltipStyle }
formatter = { ( value , name ) = > [
formatMoney ( Number ( value ) , currency ) ,
String ( name ) ,
] }
/ >
< / PieChart >
< / ResponsiveContainer >
) }
< / CardContent >
< / Card >
< Card >
< CardHeader >
< CardTitle className = "text-base" > Recent payments < / CardTitle >
< p className = "text-xs text-muted-foreground" > Latest money received . < / p >
< / CardHeader >
< CardContent className = "px-0" >
{ isLoading ? (
< Skeleton className = "mx-6 h-[260px]" / >
) : recentPayments . length === 0 ? (
< EmptyState > No payments recorded in this period . < / EmptyState >
) : (
< SimpleTable
head = { [ 'Date' , 'Client' , 'Type' , 'Amount' ] }
rows = { recentPayments . slice ( 0 , 8 ) . map ( ( p ) = > [
p . receivedAt ? p . receivedAt . slice ( 0 , 10 ) : '—' ,
p . clientName ,
< span key = "t" className = "capitalize" >
{ p . paymentType }
< / span > ,
< span key = "a" className = "tabular-nums" >
{ formatMoney ( p . amount , p . currency ) }
< / span > ,
] ) }
/ >
) }
< / CardContent >
< / Card >
< / div >
{ /* TABLES — Outstanding deposits + Expense ledger + Refunds */ }
< Card >
< CardHeader >
< CardTitle className = "text-base" > Outstanding deposits < / CardTitle >
< p className = "text-xs text-muted-foreground" >
Open deals with an expected deposit not yet fully collected . The chase list .
< / p >
< / CardHeader >
< CardContent className = "px-0" >
{ isLoading ? (
< Skeleton className = "mx-6 h-[200px]" / >
) : outstandingDeposits . length === 0 ? (
< EmptyState > Nothing outstanding . Every open deal is paid up . < / EmptyState >
) : (
< SimpleTable
head = { [ 'Client' , 'Berth' , 'Expected' , 'Collected' , 'Remaining' , 'Age' ] }
rows = { outstandingDeposits . slice ( 0 , 12 ) . map ( ( r ) = > [
r . clientName ,
r . mooring ? ? '—' ,
< span key = "e" className = "tabular-nums" >
{ formatMoney ( r . expected , r . currency ) }
< / span > ,
< span key = "c" className = "tabular-nums" >
{ formatMoney ( r . collected , r . currency ) }
< / span > ,
< span key = "r" className = "tabular-nums font-medium" >
{ formatMoney ( r . remaining , r . currency ) }
< / span > ,
` ${ r . daysOutstanding } d ` ,
] ) }
/ >
) }
< / CardContent >
< / Card >
< div className = "grid gap-4 lg:grid-cols-2" >
< Card >
< CardHeader >
< CardTitle className = "text-base" > Expense ledger < / CardTitle >
< p className = "text-xs text-muted-foreground" > Expenses booked in the period . < / p >
< / CardHeader >
< CardContent className = "px-0" >
{ isLoading ? (
< Skeleton className = "mx-6 h-[200px]" / >
) : expenseLedger . length === 0 ? (
< EmptyState > No expenses booked in this period . < / EmptyState >
) : (
< SimpleTable
head = { [ 'Date' , 'Category' , 'Payer' , 'Amount' , 'Status' ] }
rows = { expenseLedger . slice ( 0 , 10 ) . map ( ( r ) = > [
r . expenseDate ? r . expenseDate . slice ( 0 , 10 ) : '—' ,
r . category ? ? '—' ,
r . payer ? ? '—' ,
< span key = "a" className = "tabular-nums" >
{ formatMoney ( r . amount , r . currency ) }
< / span > ,
< span key = "s" className = "capitalize" >
{ r . paymentStatus ? ? '—' }
< / span > ,
] ) }
/ >
) }
< / CardContent >
< / Card >
< Card >
< CardHeader >
< CardTitle className = "text-base" > Refunds & amp ; write - offs < / CardTitle >
< p className = "text-xs text-muted-foreground" >
Refund - type payments issued in the period .
< / p >
< / CardHeader >
< CardContent className = "px-0" >
{ isLoading ? (
< Skeleton className = "mx-6 h-[200px]" / >
) : refundLog . length === 0 ? (
< EmptyState > No refunds in this period . < / EmptyState >
) : (
< SimpleTable
head = { [ 'Date' , 'Client' , 'Amount' , 'Notes' ] }
rows = { refundLog . slice ( 0 , 10 ) . map ( ( r ) = > [
r . receivedAt ? r . receivedAt . slice ( 0 , 10 ) : '—' ,
r . clientName ,
< span key = "a" className = "tabular-nums" >
{ formatMoney ( r . amount , r . currency ) }
< / span > ,
r . notes ? ? '—' ,
] ) }
/ >
) }
< / CardContent >
< / Card >
< / div >
< / div >
) ;
}
// ─── helpers + primitives ────────────────────────────────────────────────────
const tooltipStyle = {
background : 'hsl(var(--popover))' ,
border : '1px solid hsl(var(--border))' ,
borderRadius : '6px' ,
fontSize : 12 ,
} as const ;
function formatMonthLabel ( month : string ) : string {
const [ year , m ] = month . split ( '-' ) ;
if ( ! year || ! m ) return month ;
const date = new Date ( parseInt ( year ) , parseInt ( m ) - 1 , 1 ) ;
return date . toLocaleDateString ( undefined , { month : 'short' , year : '2-digit' } ) ;
}
/** Re-bucket the monthly revenue series to month / quarter / year for the toggle. */
function rebucketRevenue (
rows : RevenueByMonthRow [ ] ,
granularity : MonthGranularity ,
) : Array < { label : string ; deposit : number ; balance : number } > {
if ( granularity === 'month' ) {
return rows . map ( ( r ) = > ( {
label : formatMonthLabel ( r . month ) ,
deposit : r.deposit ,
balance : r.balance ,
} ) ) ;
}
const byKey = new Map < string , { deposit : number ; balance : number } > ( ) ;
for ( const r of rows ) {
const [ year , m ] = r . month . split ( '-' ) ;
if ( ! year || ! m ) continue ;
const key = granularity === 'year' ? year : ` ${ year } -Q ${ Math . floor ( ( parseInt ( m ) - 1 ) / 3 ) + 1 } ` ;
const acc = byKey . get ( key ) ? ? { deposit : 0 , balance : 0 } ;
acc . deposit += r . deposit ;
acc . balance += r . balance ;
byKey . set ( key , acc ) ;
}
return Array . from ( byKey . entries ( ) ) . map ( ( [ label , v ] ) = > ( { label , . . . v } ) ) ;
}
interface KpiCardProps {
label : string ;
value : string ;
hint? : string ;
valueTone ? : 'positive' | 'negative' | 'neutral' ;
}
function KpiCard ( { label , value , hint , valueTone = 'neutral' } : KpiCardProps ) {
return (
< Card className = "h-full p-4 space-y-1.5" >
< p className = "text-[11px] font-medium uppercase tracking-wider text-muted-foreground" >
{ label }
< / p >
< p
className = {
'text-2xl font-semibold tracking-tight tabular-nums ' +
( valueTone === 'positive'
? 'text-emerald-600'
: valueTone === 'negative'
? 'text-rose-600'
: 'text-foreground' )
}
>
{ value }
< / p >
{ hint ? (
< p className = "text-[11px] text-muted-foreground leading-snug line-clamp-2" > { hint } < / p >
) : null }
< / Card >
) ;
}
function KpiSkeleton() {
return (
< Card className = "h-full p-4 space-y-2" >
< Skeleton className = "h-3 w-20" / >
< Skeleton className = "h-7 w-24" / >
< Skeleton className = "h-3 w-28" / >
< / Card >
) ;
}
function EmptyState ( { children } : { children : React.ReactNode } ) {
return (
< div className = "py-12 flex flex-col items-center justify-center text-center space-y-2" >
< Badge variant = "outline" className = "text-muted-foreground" >
No data
< / Badge >
< p className = "text-sm text-muted-foreground max-w-xs" > { children } < / p >
< / div >
) ;
}
function SimpleTable ( { head , rows } : { head : string [ ] ; rows : React.ReactNode [ ] [ ] } ) {
return (
< div className = "overflow-x-auto" >
< table className = "w-full text-sm" >
< thead >
< tr className = "border-b border-border text-start" >
{ head . map ( ( h ) = > (
< th
key = { h }
className = "px-6 py-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground"
>
{ h }
< / th >
) ) }
< / tr >
< / thead >
< tbody >
{ rows . map ( ( cells , i ) = > (
< tr key = { i } className = "border-b border-border/50 last:border-0" >
{ cells . map ( ( c , j ) = > (
< td key = { j } className = "px-6 py-2 text-foreground" >
{ c }
< / td >
) ) }
< / tr >
) ) }
< / tbody >
< / table >
< / div >
) ;
}