feat: Implement Day-End Sales report fe
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Successful in 2m38s
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 13m9s

This commit is contained in:
2026-10-23 14:54:40 +05:30
parent 15e4738dd4
commit e175a7a040
5 changed files with 507 additions and 1 deletions
@@ -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>
)
}