Dev #89

Merged
DeepnaPooja merged 3 commits from dev into production 2026-10-23 15:00:30 +05:30
5 changed files with 507 additions and 1 deletions
Showing only changes of commit e175a7a040 - Show all commits
@@ -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>
)
}
+1
View File
@@ -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' },
],
},
]
+1 -1
View File
@@ -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'
+40
View File
@@ -231,3 +231,43 @@ export const paymentReports = {
analytics: (params?: { from?: string; to?: string; regionId?: string }) =>
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),
}