feat: Implement Day-End Sales report fe
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { dayEndReports, getApiError } from '@/lib/api/index'
|
||||
import type { DayEndDailyResponse, DayEndResponse, DayEndRow } from '@/lib/api/reports'
|
||||
import { exportPdf, type ExportColumn } from '@/lib/export'
|
||||
|
||||
import { daysFromToday, fmtCompact, fmtMoney, fmtNum, fmtShortDay } from '@/app/(dashboard)/network/warehouses/_components/dashboard/format'
|
||||
import { EmptyNote, Metric, Section } from '@/app/(dashboard)/network/warehouses/_components/dashboard/shared'
|
||||
|
||||
const PRESETS = [
|
||||
{ label: '7 days', days: -6 },
|
||||
{ label: '30 days', days: -29 },
|
||||
{ label: '92 days', days: -91 },
|
||||
]
|
||||
|
||||
const trendConfig = {
|
||||
totalSales: { label: 'Sales', color: 'var(--chart-1)' },
|
||||
expenses: { label: 'Expenses', color: 'var(--chart-3)' },
|
||||
income: { label: 'Income', color: 'var(--chart-2)' },
|
||||
} satisfies ChartConfig
|
||||
|
||||
const pdfColumns: ExportColumn<DayEndRow>[] = [
|
||||
{ header: 'Date', value: r => r.date },
|
||||
{ header: 'Sales', value: r => fmtNum(r.totalSales), numeric: true },
|
||||
{ header: 'Collected', value: r => fmtNum(r.collected), numeric: true },
|
||||
{ header: 'Due', value: r => fmtNum(r.due), numeric: true },
|
||||
{ header: 'Cash', value: r => fmtNum(r.cash), numeric: true },
|
||||
{ header: 'Cheque', value: r => fmtNum(r.cheque), numeric: true },
|
||||
{ header: 'Credit', value: r => fmtNum(r.credit), numeric: true },
|
||||
{ header: 'Returns', value: r => fmtNum(r.returns), numeric: true },
|
||||
{ header: 'Expenses', value: r => fmtNum(r.expenses), numeric: true },
|
||||
{ header: 'Income', value: r => fmtNum(r.income), numeric: true },
|
||||
]
|
||||
|
||||
const incomeTone = (v: number) => (v < 0 ? 'danger' : undefined)
|
||||
|
||||
export default function DayEndReportPage() {
|
||||
const [date, setDate] = useState(() => daysFromToday(0))
|
||||
const [day, setDay] = useState<DayEndResponse | null>(null)
|
||||
const [dayError, setDayError] = useState<string | null>(null)
|
||||
const [dayLoading, setDayLoading] = useState(true)
|
||||
|
||||
const [from, setFrom] = useState(() => daysFromToday(-29))
|
||||
const [to, setTo] = useState(() => daysFromToday(0))
|
||||
const [daily, setDaily] = useState<DayEndDailyResponse | null>(null)
|
||||
const [dailyError, setDailyError] = useState<string | null>(null)
|
||||
const [dailyLoading, setDailyLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!date) return
|
||||
let cancelled = false
|
||||
setDayLoading(true)
|
||||
setDayError(null)
|
||||
dayEndReports.day({ date })
|
||||
.then(res => { if (!cancelled) setDay(res) })
|
||||
.catch(err => { if (!cancelled) setDayError(getApiError(err)) })
|
||||
.finally(() => { if (!cancelled) setDayLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [date])
|
||||
|
||||
useEffect(() => {
|
||||
if (!from || !to) return
|
||||
let cancelled = false
|
||||
setDailyLoading(true)
|
||||
setDailyError(null)
|
||||
dayEndReports.daily({ from, to })
|
||||
.then(res => { if (!cancelled) setDaily(res) })
|
||||
.catch(err => { if (!cancelled) setDailyError(getApiError(err)) })
|
||||
.finally(() => { if (!cancelled) setDailyLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [from, to])
|
||||
|
||||
const s = day?.summary
|
||||
const hasActivity = !!daily && daily.days.some(d => d.totalSales > 0 || d.expenses > 0)
|
||||
|
||||
const downloadPdf = () => {
|
||||
if (!daily) return
|
||||
void exportPdf(
|
||||
'day-end-sales',
|
||||
'Day-End Sales Report',
|
||||
pdfColumns,
|
||||
[...daily.days, daily.totals],
|
||||
`${from} to ${to}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Day-End Sales</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Sales, collections, returns, expenses and income for a day.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input type="date" value={date} max={daysFromToday(0)} onChange={e => setDate(e.target.value)} className="w-40" aria-label="Report date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dayError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{dayError}</div>
|
||||
)}
|
||||
|
||||
{!dayError && !day && dayLoading && <Skeleton className="h-24 w-full" />}
|
||||
|
||||
{day && s && (
|
||||
<div className={dayLoading ? 'flex flex-col gap-6 opacity-60 transition-opacity' : 'flex flex-col gap-6'}>
|
||||
<div className="grid grid-cols-2 gap-3 lg:grid-cols-3 xl:grid-cols-5">
|
||||
<Metric label="Total sales" value={fmtMoney(s.totalSales)} sub={`${fmtNum(s.invoiceCount)} invoices`} />
|
||||
<Metric label="Collected" value={fmtMoney(s.collected)} sub={`${fmtNum(s.paymentCount)} payments · cash + cheque`} />
|
||||
<Metric label="Due" value={fmtMoney(s.due)} sub="Unpaid on today's invoices" tone={s.due > 0 ? 'warning' : undefined} />
|
||||
<Metric label="Cash" value={fmtMoney(s.cash)} sub={`${fmtNum(day.byMethod.find(m => m.method === 'Cash')?.count ?? 0)} payments`} />
|
||||
<Metric label="Cheque" value={fmtMoney(s.cheque)} sub={`${fmtNum(day.byMethod.find(m => m.method === 'Cheque')?.count ?? 0)} payments`} />
|
||||
<Metric label="Credit" value={fmtMoney(s.credit)} sub="Extended, not received" />
|
||||
<Metric label="Returns" value={fmtMoney(s.returns)} />
|
||||
<Metric label="Expenses" value={fmtMoney(s.expenses)} />
|
||||
<Metric label="Net cash" value={fmtMoney(s.netCash)} sub="Cash collected − cash expenses paid" tone={incomeTone(s.netCash)} />
|
||||
<Metric label="Income" value={fmtMoney(s.income)} sub="Sales − returns − expenses" tone={incomeTone(s.income)} />
|
||||
</div>
|
||||
|
||||
<Section title="Expenses" description="Posted and paid expenses billed on this day">
|
||||
{day.expenses.length === 0 ? (
|
||||
<EmptyNote>No expenses on this day.</EmptyNote>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Payee</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Method</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{day.expenses.map(e => (
|
||||
<TableRow key={e.id}>
|
||||
<TableCell className="font-medium">{e.category}</TableCell>
|
||||
<TableCell>{e.payee ?? '—'}</TableCell>
|
||||
<TableCell>{e.description ?? '—'}</TableCell>
|
||||
<TableCell>{e.method ?? 'Unpaid'}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtMoney(e.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Up to 92 days.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{PRESETS.map(p => (
|
||||
<Button
|
||||
key={p.label}
|
||||
variant={from === daysFromToday(p.days) && to === daysFromToday(0) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => { setFrom(daysFromToday(p.days)); setTo(daysFromToday(0)) }}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
<Input type="date" value={from} max={to} onChange={e => setFrom(e.target.value)} className="w-40" aria-label="From date" />
|
||||
<Input type="date" value={to} min={from} onChange={e => setTo(e.target.value)} className="w-40" aria-label="To date" />
|
||||
<Button size="sm" variant="outline" onClick={downloadPdf} disabled={!daily || dailyLoading}>Download PDF</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dailyError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{dailyError}</div>
|
||||
)}
|
||||
|
||||
{!dailyError && !daily && dailyLoading && <Skeleton className="h-64 w-full" />}
|
||||
|
||||
{daily && (
|
||||
<div className={dailyLoading ? 'flex flex-col gap-6 opacity-60 transition-opacity' : 'flex flex-col gap-6'}>
|
||||
<Section title="Sales, expenses and income" description="Per day over the selected range">
|
||||
{!hasActivity ? (
|
||||
<EmptyNote>No sales or expenses in this period.</EmptyNote>
|
||||
) : (
|
||||
<ChartContainer config={trendConfig} className="w-full" style={{ height: 280 }}>
|
||||
<LineChart data={daily.days} margin={{ left: 8 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis dataKey="date" tickFormatter={fmtShortDay} tickLine={false} axisLine={false} minTickGap={16} />
|
||||
<YAxis tickFormatter={fmtCompact} tickLine={false} axisLine={false} width={48} />
|
||||
<ChartTooltip content={<ChartTooltipContent labelFormatter={(l) => fmtShortDay(String(l))} formatter={(v) => fmtMoney(Number(v))} />} />
|
||||
<Line dataKey="totalSales" type="monotone" stroke="var(--color-totalSales)" strokeWidth={2} dot={false} />
|
||||
<Line dataKey="expenses" type="monotone" stroke="var(--color-expenses)" strokeWidth={2} dot={false} />
|
||||
<Line dataKey="income" type="monotone" stroke="var(--color-income)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="Daily breakdown">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Sales</TableHead>
|
||||
<TableHead className="text-right">Collected</TableHead>
|
||||
<TableHead className="text-right">Due</TableHead>
|
||||
<TableHead className="text-right">Cash</TableHead>
|
||||
<TableHead className="text-right">Cheque</TableHead>
|
||||
<TableHead className="text-right">Credit</TableHead>
|
||||
<TableHead className="text-right">Returns</TableHead>
|
||||
<TableHead className="text-right">Expenses</TableHead>
|
||||
<TableHead className="text-right">Income</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...daily.days].reverse().map(r => (
|
||||
<TableRow key={r.date}>
|
||||
<TableCell className="font-medium">{r.date}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.totalSales)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.collected)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.due)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.cash)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.cheque)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.credit)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.returns)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(r.expenses)}</TableCell>
|
||||
<TableCell className={`text-right tabular-nums ${r.income < 0 ? 'text-destructive' : ''}`}>{fmtNum(r.income)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="font-semibold">
|
||||
<TableCell>Total</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.totalSales)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.collected)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.due)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.cash)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.cheque)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.credit)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.returns)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{fmtNum(daily.totals.expenses)}</TableCell>
|
||||
<TableCell className={`text-right tabular-nums ${daily.totals.income < 0 ? 'text-destructive' : ''}`}>{fmtNum(daily.totals.income)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user