feat: Implement Day-End Sales report fe
This commit is contained in:
@@ -0,0 +1,208 @@
|
|||||||
|
using Distribution.Features.DayEndReport;
|
||||||
|
using Distribution.Models;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace Distribution.Tests.Services;
|
||||||
|
|
||||||
|
public class DayEndReportServiceTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Day = new(2026, 10, 20);
|
||||||
|
|
||||||
|
private readonly Mock<IDayEndReportRepository> _repo = new();
|
||||||
|
private readonly DayEndReportService _sut;
|
||||||
|
|
||||||
|
public DayEndReportServiceTests()
|
||||||
|
{
|
||||||
|
_sut = new DayEndReportService(_repo.Object);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Returns(DayEndData data) =>
|
||||||
|
_repo.Setup(r => r.GetDataAsync(It.IsAny<DayEndFilter>())).ReturnsAsync(data);
|
||||||
|
|
||||||
|
private static DayEndData Data(
|
||||||
|
List<DayEndInvoiceRow>? invoices = null,
|
||||||
|
List<DayEndPaymentRow>? payments = null,
|
||||||
|
List<DayEndReturnRow>? returns = null,
|
||||||
|
List<DayEndExpenseRow>? expenses = null) =>
|
||||||
|
new(invoices ?? [], payments ?? [], returns ?? [], expenses ?? []);
|
||||||
|
|
||||||
|
private static DayEndExpenseRow Expense(DateTime billDate, decimal amount, PaymentMethod? method = null, DateTime? paidAt = null) =>
|
||||||
|
new(Guid.NewGuid(), billDate, paidAt, method, amount, "Utilities", "Payee", null);
|
||||||
|
|
||||||
|
// Validate / BuildFilter
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ReturnsError_WhenToBeforeFrom()
|
||||||
|
{
|
||||||
|
var f = new DayEndFilter(Day, Day.AddDays(-1));
|
||||||
|
Assert.NotNull(DayEndReportService.Validate(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ReturnsError_WhenRangeTooLong()
|
||||||
|
{
|
||||||
|
var f = new DayEndFilter(Day, Day.AddDays(DayEndReportService.MaxRangeDays));
|
||||||
|
Assert.NotNull(DayEndReportService.Validate(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_ReturnsNull_ForMaxAllowedRange()
|
||||||
|
{
|
||||||
|
var f = new DayEndFilter(Day, Day.AddDays(DayEndReportService.MaxRangeDays - 1));
|
||||||
|
Assert.Null(DayEndReportService.Validate(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDayAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDayAsync_ComputesSalesCollectionsAndIncome()
|
||||||
|
{
|
||||||
|
var inv1 = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 1000m);
|
||||||
|
var inv2 = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(15), 500m);
|
||||||
|
Returns(Data(
|
||||||
|
invoices: [inv1, inv2],
|
||||||
|
payments:
|
||||||
|
[
|
||||||
|
new(inv1.Id, PaymentMethod.Cash, 600m, Day.AddHours(11)),
|
||||||
|
new(inv1.Id, PaymentMethod.Cheque, 400m, Day.AddHours(12)),
|
||||||
|
new(inv2.Id, PaymentMethod.Credit, 500m, Day.AddHours(16)),
|
||||||
|
],
|
||||||
|
returns: [new(Day.AddHours(9), 100m)],
|
||||||
|
expenses: [Expense(Day, 250m)]));
|
||||||
|
|
||||||
|
var result = await _sut.GetDayAsync(Day);
|
||||||
|
|
||||||
|
var s = result.Summary;
|
||||||
|
Assert.Equal(1500m, s.TotalSales);
|
||||||
|
Assert.Equal(1000m, s.Collected);
|
||||||
|
Assert.Equal(500m, s.Due);
|
||||||
|
Assert.Equal(600m, s.Cash);
|
||||||
|
Assert.Equal(400m, s.Cheque);
|
||||||
|
Assert.Equal(500m, s.Credit);
|
||||||
|
Assert.Equal(100m, s.Returns);
|
||||||
|
Assert.Equal(250m, s.Expenses);
|
||||||
|
Assert.Equal(1150m, s.Income);
|
||||||
|
Assert.Equal(2, s.InvoiceCount);
|
||||||
|
Assert.Equal(3, s.PaymentCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDayAsync_CreditPayment_DoesNotReduceDue()
|
||||||
|
{
|
||||||
|
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 800m);
|
||||||
|
Returns(Data(invoices: [inv], payments: [new(inv.Id, PaymentMethod.Credit, 800m, Day.AddHours(10))]));
|
||||||
|
|
||||||
|
var result = await _sut.GetDayAsync(Day);
|
||||||
|
|
||||||
|
Assert.Equal(800m, result.Summary.Due);
|
||||||
|
Assert.Equal(0m, result.Summary.Collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDayAsync_PaymentAfterDayEnd_DoesNotReduceThatDaysDue()
|
||||||
|
{
|
||||||
|
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 800m);
|
||||||
|
Returns(Data(invoices: [inv], payments: [new(inv.Id, PaymentMethod.Cash, 800m, Day.AddDays(1).AddHours(1))]));
|
||||||
|
|
||||||
|
var result = await _sut.GetDayAsync(Day);
|
||||||
|
|
||||||
|
Assert.Equal(800m, result.Summary.Due);
|
||||||
|
Assert.Equal(0m, result.Summary.Collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDayAsync_InstalmentOnOlderInvoice_CountsAsCollectedNotSales()
|
||||||
|
{
|
||||||
|
var older = Guid.NewGuid();
|
||||||
|
Returns(Data(payments: [new(older, PaymentMethod.Cash, 300m, Day.AddHours(9))]));
|
||||||
|
|
||||||
|
var result = await _sut.GetDayAsync(Day);
|
||||||
|
|
||||||
|
Assert.Equal(0m, result.Summary.TotalSales);
|
||||||
|
Assert.Equal(300m, result.Summary.Collected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDayAsync_Overpayment_DueIsNeverNegative()
|
||||||
|
{
|
||||||
|
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 100m);
|
||||||
|
Returns(Data(invoices: [inv], payments: [new(inv.Id, PaymentMethod.Cash, 150m, Day.AddHours(11))]));
|
||||||
|
|
||||||
|
var result = await _sut.GetDayAsync(Day);
|
||||||
|
|
||||||
|
Assert.Equal(0m, result.Summary.Due);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDayAsync_OnlyCountsExpensesBilledThatDay()
|
||||||
|
{
|
||||||
|
Returns(Data(expenses:
|
||||||
|
[
|
||||||
|
Expense(Day, 100m),
|
||||||
|
Expense(Day.AddDays(-1), 999m, PaymentMethod.Cash, paidAt: Day.AddHours(8)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
var result = await _sut.GetDayAsync(Day);
|
||||||
|
|
||||||
|
Assert.Equal(100m, result.Summary.Expenses);
|
||||||
|
Assert.Single(result.Expenses);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDayAsync_NetCash_IsCashCollectedMinusCashExpensesPaid()
|
||||||
|
{
|
||||||
|
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 1000m);
|
||||||
|
Returns(Data(
|
||||||
|
invoices: [inv],
|
||||||
|
payments:
|
||||||
|
[
|
||||||
|
new(inv.Id, PaymentMethod.Cash, 700m, Day.AddHours(11)),
|
||||||
|
new(inv.Id, PaymentMethod.Cheque, 300m, Day.AddHours(11)),
|
||||||
|
],
|
||||||
|
expenses:
|
||||||
|
[
|
||||||
|
Expense(Day, 200m, PaymentMethod.Cash, paidAt: Day.AddHours(14)),
|
||||||
|
Expense(Day, 50m, PaymentMethod.Cheque, paidAt: Day.AddHours(14)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
var result = await _sut.GetDayAsync(Day);
|
||||||
|
|
||||||
|
Assert.Equal(700m, result.Summary.CashCollected);
|
||||||
|
Assert.Equal(200m, result.Summary.CashExpensesPaid);
|
||||||
|
Assert.Equal(500m, result.Summary.NetCash);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDayAsync_NoActivity_ReturnsZeros()
|
||||||
|
{
|
||||||
|
Returns(Data());
|
||||||
|
|
||||||
|
var result = await _sut.GetDayAsync(Day);
|
||||||
|
|
||||||
|
Assert.Equal(0m, result.Summary.Income);
|
||||||
|
Assert.Equal(3, result.ByMethod.Count);
|
||||||
|
Assert.Empty(result.Expenses);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDailyAsync
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetDailyAsync_FillsEmptyDays_AndTotalsMatchSumOfDays()
|
||||||
|
{
|
||||||
|
var inv = new DayEndInvoiceRow(Guid.NewGuid(), Day.AddHours(10), 1000m);
|
||||||
|
Returns(Data(
|
||||||
|
invoices: [inv],
|
||||||
|
payments: [new(inv.Id, PaymentMethod.Cash, 1000m, Day.AddHours(11))],
|
||||||
|
expenses: [Expense(Day.AddDays(2), 300m)]));
|
||||||
|
|
||||||
|
var result = await _sut.GetDailyAsync(new DayEndFilter(Day, Day.AddDays(2)));
|
||||||
|
|
||||||
|
Assert.Equal(3, result.Days.Count);
|
||||||
|
Assert.Equal(0m, result.Days[1].TotalSales);
|
||||||
|
Assert.Equal(1000m, result.Totals.TotalSales);
|
||||||
|
Assert.Equal(300m, result.Totals.Expenses);
|
||||||
|
Assert.Equal(700m, result.Totals.Income);
|
||||||
|
Assert.Equal(result.Days.Sum(d => d.Income), result.Totals.Income);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -193,6 +193,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' },
|
{ title: 'Product Report', href: '/reports/products', icon: ClipboardListIcon, description: 'Stock, sales and returns per product', permission: 'warehouses.view' },
|
||||||
{ title: 'Stock Report', href: '/reports/stock', icon: Package01Icon, description: 'Opening, in, out and closing stock, low stock and expiry', permission: 'warehouses.view' },
|
{ title: 'Stock Report', href: '/reports/stock', icon: Package01Icon, description: 'Opening, in, out and closing stock, low stock and expiry', permission: 'warehouses.view' },
|
||||||
|
{ title: 'Day-End Sales', href: '/reports/day-end', icon: ClipboardListIcon, description: 'Daily sales, collections, expenses and income', permission: 'warehouses.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers'
|
|||||||
export { purchaseOrders, grn, purchaseReturns } from './procurement'
|
export { purchaseOrders, grn, purchaseReturns } from './procurement'
|
||||||
export { rides, loadingSheets, deliveryTasks } from './operations'
|
export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||||
export { stock } from './stock'
|
export { stock } from './stock'
|
||||||
export { productReports, stockReports, dashboardReports, paymentReports } from './reports'
|
export { productReports, stockReports, dashboardReports, paymentReports, dayEndReports } from './reports'
|
||||||
export { shopPos, invoices, payments } from './sales'
|
export { shopPos, invoices, payments } from './sales'
|
||||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||||
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS, invoiceCreationMode, INVOICE_CREATION_MODE_KEY, DEFAULT_INVOICE_CREATION_MODE } from './sys-config'
|
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS, invoiceCreationMode, INVOICE_CREATION_MODE_KEY, DEFAULT_INVOICE_CREATION_MODE } from './sys-config'
|
||||||
|
|||||||
@@ -231,3 +231,43 @@ export const paymentReports = {
|
|||||||
analytics: (params?: { from?: string; to?: string; regionId?: string }) =>
|
analytics: (params?: { from?: string; to?: string; regionId?: string }) =>
|
||||||
api.get<PaymentAnalyticsResponse>('/reports/payments/analytics', { params }).then(r => r.data),
|
api.get<PaymentAnalyticsResponse>('/reports/payments/analytics', { params }).then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DayEndRow {
|
||||||
|
date: string
|
||||||
|
totalSales: number
|
||||||
|
collected: number
|
||||||
|
due: number
|
||||||
|
cash: number
|
||||||
|
cheque: number
|
||||||
|
credit: number
|
||||||
|
returns: number
|
||||||
|
expenses: number
|
||||||
|
income: number
|
||||||
|
invoiceCount: number
|
||||||
|
paymentCount: number
|
||||||
|
cashCollected: number
|
||||||
|
cashExpensesPaid: number
|
||||||
|
netCash: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayEndResponse {
|
||||||
|
date: string
|
||||||
|
summary: DayEndRow
|
||||||
|
byMethod: Array<{ method: 'Cash' | 'Cheque' | 'Credit'; count: number; amount: number }>
|
||||||
|
expenses: Array<{ id: string; category: string; payee: string | null; description: string | null; method: string | null; amount: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayEndDailyResponse {
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
days: DayEndRow[]
|
||||||
|
totals: DayEndRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dayEndReports = {
|
||||||
|
day: (params?: { date?: string }) =>
|
||||||
|
api.get<DayEndResponse>('/reports/day-end', { params }).then(r => r.data),
|
||||||
|
|
||||||
|
daily: (params?: { from?: string; to?: string }) =>
|
||||||
|
api.get<DayEndDailyResponse>('/reports/day-end/daily', { params }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user