From e175a7a040c0f3418f2464c862921e25e02f2422 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 23 Oct 2026 14:54:40 +0530 Subject: [PATCH] feat: Implement Day-End Sales report fe --- .../Services/DayEndReportServiceTests.cs | 208 ++++++++++++++ .../app/(dashboard)/reports/day-end/page.tsx | 257 ++++++++++++++++++ distribution_fe/config/nav.ts | 1 + distribution_fe/lib/api/index.ts | 2 +- distribution_fe/lib/api/reports.ts | 40 +++ 5 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 Distribution.Tests/Services/DayEndReportServiceTests.cs create mode 100644 distribution_fe/app/(dashboard)/reports/day-end/page.tsx diff --git a/Distribution.Tests/Services/DayEndReportServiceTests.cs b/Distribution.Tests/Services/DayEndReportServiceTests.cs new file mode 100644 index 0000000..8c4655e --- /dev/null +++ b/Distribution.Tests/Services/DayEndReportServiceTests.cs @@ -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 _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())).ReturnsAsync(data); + + private static DayEndData Data( + List? invoices = null, + List? payments = null, + List? returns = null, + List? 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); + } +} diff --git a/distribution_fe/app/(dashboard)/reports/day-end/page.tsx b/distribution_fe/app/(dashboard)/reports/day-end/page.tsx new file mode 100644 index 0000000..b0fc5a6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/reports/day-end/page.tsx @@ -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[] = [ + { 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(null) + const [dayError, setDayError] = useState(null) + const [dayLoading, setDayLoading] = useState(true) + + const [from, setFrom] = useState(() => daysFromToday(-29)) + const [to, setTo] = useState(() => daysFromToday(0)) + const [daily, setDaily] = useState(null) + const [dailyError, setDailyError] = useState(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 ( +
+
+
+

Day-End Sales

+

Sales, collections, returns, expenses and income for a day.

+
+
+ setDate(e.target.value)} className="w-40" aria-label="Report date" /> +
+
+ + {dayError && ( +
{dayError}
+ )} + + {!dayError && !day && dayLoading && } + + {day && s && ( +
+
+ + + 0 ? 'warning' : undefined} /> + m.method === 'Cash')?.count ?? 0)} payments`} /> + m.method === 'Cheque')?.count ?? 0)} payments`} /> + + + + + +
+ +
+ {day.expenses.length === 0 ? ( + No expenses on this day. + ) : ( + + + + Category + Payee + Description + Method + Amount + + + + {day.expenses.map(e => ( + + {e.category} + {e.payee ?? '—'} + {e.description ?? '—'} + {e.method ?? 'Unpaid'} + {fmtMoney(e.amount)} + + ))} + +
+ )} +
+
+ )} + +
+
+

Day-wise income

+

Up to 92 days.

+
+
+ {PRESETS.map(p => ( + + ))} + setFrom(e.target.value)} className="w-40" aria-label="From date" /> + setTo(e.target.value)} className="w-40" aria-label="To date" /> + +
+
+ + {dailyError && ( +
{dailyError}
+ )} + + {!dailyError && !daily && dailyLoading && } + + {daily && ( +
+
+ {!hasActivity ? ( + No sales or expenses in this period. + ) : ( + + + + + + fmtShortDay(String(l))} formatter={(v) => fmtMoney(Number(v))} />} /> + + + + + + )} +
+ +
+ + + + Date + Sales + Collected + Due + Cash + Cheque + Credit + Returns + Expenses + Income + + + + {[...daily.days].reverse().map(r => ( + + {r.date} + {fmtNum(r.totalSales)} + {fmtNum(r.collected)} + {fmtNum(r.due)} + {fmtNum(r.cash)} + {fmtNum(r.cheque)} + {fmtNum(r.credit)} + {fmtNum(r.returns)} + {fmtNum(r.expenses)} + {fmtNum(r.income)} + + ))} + + Total + {fmtNum(daily.totals.totalSales)} + {fmtNum(daily.totals.collected)} + {fmtNum(daily.totals.due)} + {fmtNum(daily.totals.cash)} + {fmtNum(daily.totals.cheque)} + {fmtNum(daily.totals.credit)} + {fmtNum(daily.totals.returns)} + {fmtNum(daily.totals.expenses)} + {fmtNum(daily.totals.income)} + + +
+
+
+ )} +
+ ) +} diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index a77c595..2400590 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -193,6 +193,7 @@ export const NAV_GROUPS: NavGroup[] = [ items: [ { 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: 'Day-End Sales', href: '/reports/day-end', icon: ClipboardListIcon, description: 'Daily sales, collections, expenses and income', permission: 'warehouses.view' }, ], }, ] diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts index 1050e94..6475423 100644 --- a/distribution_fe/lib/api/index.ts +++ b/distribution_fe/lib/api/index.ts @@ -8,7 +8,7 @@ export { drivers, compliance } from './drivers' export { purchaseOrders, grn, purchaseReturns } from './procurement' export { rides, loadingSheets, deliveryTasks } from './operations' 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 { 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' diff --git a/distribution_fe/lib/api/reports.ts b/distribution_fe/lib/api/reports.ts index 098689d..096d334 100644 --- a/distribution_fe/lib/api/reports.ts +++ b/distribution_fe/lib/api/reports.ts @@ -231,3 +231,43 @@ export const paymentReports = { analytics: (params?: { from?: string; to?: string; regionId?: string }) => api.get('/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('/reports/day-end', { params }).then(r => r.data), + + daily: (params?: { from?: string; to?: string }) => + api.get('/reports/day-end/daily', { params }).then(r => r.data), +}