From 6674ba6aae956396cd5d0604952c10cb0da07ff1 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 23 Oct 2026 14:52:39 +0530 Subject: [PATCH 1/3] feat: Implement Day End Report feature logic --- .../DayEndReport/DayEndReportController.cs | 20 ++++ .../Features/DayEndReport/DayEndReportDTOs.cs | 51 ++++++++++ .../DayEndReport/DayEndReportRepository.cs | 47 +++++++++ .../DayEndReport/DayEndReportService.cs | 96 +++++++++++++++++++ .../DayEndReport/IDayEndReportRepository.cs | 10 ++ 5 files changed, 224 insertions(+) create mode 100644 Distribution/Features/DayEndReport/DayEndReportController.cs create mode 100644 Distribution/Features/DayEndReport/DayEndReportDTOs.cs create mode 100644 Distribution/Features/DayEndReport/DayEndReportRepository.cs create mode 100644 Distribution/Features/DayEndReport/DayEndReportService.cs create mode 100644 Distribution/Features/DayEndReport/IDayEndReportRepository.cs diff --git a/Distribution/Features/DayEndReport/DayEndReportController.cs b/Distribution/Features/DayEndReport/DayEndReportController.cs new file mode 100644 index 0000000..b49e2ec --- /dev/null +++ b/Distribution/Features/DayEndReport/DayEndReportController.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.DayEndReport; + +[ApiController] +[Route("api/reports/day-end")] +public class DayEndReportController(DayEndReportService service) : ControllerBase +{ + [HttpGet] + public async Task Day([FromQuery] DateTime? date) => + Ok(await service.GetDayAsync(date ?? DateTime.Now)); + + [HttpGet("daily")] + public async Task Daily([FromQuery] DateTime? from, [FromQuery] DateTime? to) + { + var f = DayEndReportService.BuildFilter(from, to); + var error = DayEndReportService.Validate(f); + return error is null ? Ok(await service.GetDailyAsync(f)) : BadRequest(new { message = error }); + } +} diff --git a/Distribution/Features/DayEndReport/DayEndReportDTOs.cs b/Distribution/Features/DayEndReport/DayEndReportDTOs.cs new file mode 100644 index 0000000..1094178 --- /dev/null +++ b/Distribution/Features/DayEndReport/DayEndReportDTOs.cs @@ -0,0 +1,51 @@ +using Distribution.Models; + +namespace Distribution.Features.DayEndReport; + +public record DayEndFilter(DateTime From, DateTime To); + +public record DayEndRow( + string Date, + decimal TotalSales, + decimal Collected, + decimal Due, + decimal Cash, + decimal Cheque, + decimal Credit, + decimal Returns, + decimal Expenses, + decimal Income, + int InvoiceCount, + int PaymentCount, + decimal CashCollected, + decimal CashExpensesPaid, + decimal NetCash); + +public record DayEndMethod(string Method, int Count, decimal Amount); + +public record DayEndExpense(Guid Id, string Category, string? Payee, string? Description, string? Method, decimal Amount); + +public record DayEndResponse( + string Date, + DayEndRow Summary, + List ByMethod, + List Expenses); + +public record DayEndDailyResponse(DateTime From, DateTime To, List Days, DayEndRow Totals); + +// Repository row shapes +public record DayEndInvoiceRow(Guid Id, DateTime FinalizedAt, decimal Total); + +public record DayEndPaymentRow(Guid InvoiceId, PaymentMethod Method, decimal Amount, DateTime CollectedAt); + +public record DayEndReturnRow(DateTime At, decimal Amount); + +public record DayEndExpenseRow( + Guid Id, DateTime BillDate, DateTime? PaidAt, PaymentMethod? Method, decimal Amount, + string Category, string? Payee, string? Description); + +public record DayEndData( + List Invoices, + List Payments, + List Returns, + List Expenses); diff --git a/Distribution/Features/DayEndReport/DayEndReportRepository.cs b/Distribution/Features/DayEndReport/DayEndReportRepository.cs new file mode 100644 index 0000000..9676093 --- /dev/null +++ b/Distribution/Features/DayEndReport/DayEndReportRepository.cs @@ -0,0 +1,47 @@ +using Distribution.Data; +using Distribution.Models; +using Microsoft.EntityFrameworkCore; + +namespace Distribution.Features.DayEndReport; + +public class DayEndReportRepository(AppDBContext db) : IDayEndReportRepository +{ + public async Task GetDataAsync(DayEndFilter f) + { + var start = f.From.Date; + var end = f.To.Date.AddDays(1); + + var invoices = await db.Invoices.AsNoTracking() + .Where(i => i.IsActive && i.Status == InvoiceStatus.Finalized + && i.ShopPO.Status != ShopPOStatus.Cancelled + && i.FinalizedAt >= start && i.FinalizedAt < end) + .Select(i => new DayEndInvoiceRow(i.Id, i.FinalizedAt!.Value, i.TotalAmount)) + .ToListAsync(); + + var payments = await db.Payments.AsNoTracking() + .Where(p => p.IsActive && p.Invoice.ShopPO.Status != ShopPOStatus.Cancelled + && ((p.CollectedAt >= start && p.CollectedAt < end) + || (p.Invoice.Status == InvoiceStatus.Finalized + && p.Invoice.FinalizedAt >= start && p.Invoice.FinalizedAt < end))) + .Select(p => new DayEndPaymentRow(p.FK_InvoiceId, p.Method, p.Amount, p.CollectedAt)) + .ToListAsync(); + + var returns = await db.SaleReturns.AsNoTracking() + .Where(r => r.IsActive && r.CreatedAt >= start && r.CreatedAt < end) + .Select(r => new DayEndReturnRow(r.CreatedAt, r.TotalCreditAmount)) + .ToListAsync(); + + var expenses = await db.Expenses.AsNoTracking() + .Where(e => e.IsActive && (e.Status == ExpenseStatus.Posted || e.Status == ExpenseStatus.Paid) + && ((e.BillDate >= start && e.BillDate < end) + || (e.PaidAt >= start && e.PaidAt < end))) + .Select(e => new DayEndExpenseRow( + e.Id, e.BillDate, e.PaidAt, e.Method, e.Amount, + e.Category != null ? e.Category.Name : "", + e.Payee ?? (e.Vendor != null ? e.Vendor.Name : null), + e.Description)) + .ToListAsync(); + + return new DayEndData(invoices, payments, returns, expenses); + } +} diff --git a/Distribution/Features/DayEndReport/DayEndReportService.cs b/Distribution/Features/DayEndReport/DayEndReportService.cs new file mode 100644 index 0000000..df1e224 --- /dev/null +++ b/Distribution/Features/DayEndReport/DayEndReportService.cs @@ -0,0 +1,96 @@ +using Distribution.Models; + +namespace Distribution.Features.DayEndReport; + +public class DayEndReportService(IDayEndReportRepository repo) +{ + public const int MaxRangeDays = 92; + + public static DayEndFilter BuildFilter(DateTime? from, DateTime? to) + { + var end = (to ?? DateTime.Now).Date; + var start = (from ?? end.AddDays(-29)).Date; + return new DayEndFilter(start, end); + } + + public static string? Validate(DayEndFilter f) + { + if (f.To < f.From) return "'to' must not be before 'from'."; + if ((f.To - f.From).TotalDays >= MaxRangeDays) return $"Date range cannot exceed {MaxRangeDays} days."; + return null; + } + + public async Task GetDayAsync(DateTime date) + { + var day = date.Date; + var data = await repo.GetDataAsync(new DayEndFilter(day, day)); + var row = BuildRow(day, data); + + var payments = data.Payments.Where(p => p.CollectedAt.Date == day).ToList(); + var byMethod = Enum.GetValues().Select(m => + { + var rows = payments.Where(p => p.Method == m).ToList(); + return new DayEndMethod(m.ToString(), rows.Count, rows.Sum(p => p.Amount)); + }).ToList(); + + var expenses = data.Expenses + .Where(e => e.BillDate.Date == day) + .Select(e => new DayEndExpense(e.Id, e.Category, e.Payee, e.Description, e.Method?.ToString(), e.Amount)) + .ToList(); + + return new DayEndResponse(row.Date, row, byMethod, expenses); + } + + public async Task GetDailyAsync(DayEndFilter f) + { + var data = await repo.GetDataAsync(f); + var days = new List(); + for (var d = f.From.Date; d <= f.To.Date; d = d.AddDays(1)) + days.Add(BuildRow(d, data)); + + var totals = new DayEndRow( + "Total", + days.Sum(r => r.TotalSales), days.Sum(r => r.Collected), days.Sum(r => r.Due), + days.Sum(r => r.Cash), days.Sum(r => r.Cheque), days.Sum(r => r.Credit), + days.Sum(r => r.Returns), days.Sum(r => r.Expenses), days.Sum(r => r.Income), + days.Sum(r => r.InvoiceCount), days.Sum(r => r.PaymentCount), + days.Sum(r => r.CashCollected), days.Sum(r => r.CashExpensesPaid), days.Sum(r => r.NetCash)); + + return new DayEndDailyResponse(f.From, f.To, days, totals); + } + + // Accrual basis: Income = sales (finalized invoices) - returns - expenses billed that day. + // Due is the unpaid part of that day's invoices as at the end of the day; Credit payments never count as paid. + private static DayEndRow BuildRow(DateTime day, DayEndData data) + { + var next = day.AddDays(1); + + var invoices = data.Invoices.Where(i => i.FinalizedAt.Date == day).ToList(); + var invoiceIds = invoices.Select(i => i.Id).ToHashSet(); + var paidByInvoice = data.Payments + .Where(p => invoiceIds.Contains(p.InvoiceId) && p.Method != PaymentMethod.Credit && p.CollectedAt < next) + .GroupBy(p => p.InvoiceId) + .ToDictionary(g => g.Key, g => g.Sum(p => p.Amount)); + + var totalSales = invoices.Sum(i => i.Total); + var due = invoices.Sum(i => Math.Max(0m, i.Total - paidByInvoice.GetValueOrDefault(i.Id))); + + var payments = data.Payments.Where(p => p.CollectedAt.Date == day).ToList(); + var cash = payments.Where(p => p.Method == PaymentMethod.Cash).Sum(p => p.Amount); + var cheque = payments.Where(p => p.Method == PaymentMethod.Cheque).Sum(p => p.Amount); + var credit = payments.Where(p => p.Method == PaymentMethod.Credit).Sum(p => p.Amount); + + var returns = data.Returns.Where(r => r.At.Date == day).Sum(r => r.Amount); + var expenses = data.Expenses.Where(e => e.BillDate.Date == day).Sum(e => e.Amount); + var cashExpensesPaid = data.Expenses + .Where(e => e.Method == PaymentMethod.Cash && e.PaidAt.HasValue && e.PaidAt.Value.Date == day) + .Sum(e => e.Amount); + + return new DayEndRow( + day.ToString("yyyy-MM-dd"), + totalSales, cash + cheque, due, cash, cheque, credit, + returns, expenses, totalSales - returns - expenses, + invoices.Count, payments.Count, + cash, cashExpensesPaid, cash - cashExpensesPaid); + } +} diff --git a/Distribution/Features/DayEndReport/IDayEndReportRepository.cs b/Distribution/Features/DayEndReport/IDayEndReportRepository.cs new file mode 100644 index 0000000..729ac36 --- /dev/null +++ b/Distribution/Features/DayEndReport/IDayEndReportRepository.cs @@ -0,0 +1,10 @@ +namespace Distribution.Features.DayEndReport; + +public interface IDayEndReportRepository +{ + /// + /// Everything needed for [From, To] (whole days): invoices finalized, payments collected (plus payments on + /// those invoices, for the due amount), sale returns, and Posted/Paid expenses billed or paid in the range. + /// + Task GetDataAsync(DayEndFilter f); +} -- 2.52.0 From 15e4738dd407f7508a333abc749eed1cc4e519fb Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 23 Oct 2026 14:53:31 +0530 Subject: [PATCH 2/3] feat: Add Day End Report service and repository registration --- Distribution/Program.cs | 2 + Distribution/docs/day_end_sales.md | 133 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 Distribution/docs/day_end_sales.md diff --git a/Distribution/Program.cs b/Distribution/Program.cs index 3dff0fb..e41dd92 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -85,6 +85,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Distribution/docs/day_end_sales.md b/Distribution/docs/day_end_sales.md new file mode 100644 index 0000000..478d6fb --- /dev/null +++ b/Distribution/docs/day_end_sales.md @@ -0,0 +1,133 @@ +# Day-End Sales Report — Plan (Distribution ERP) + +A per-day view of sales, collections by method, returns, expenses and the resulting income, with a +day-wise chart and a PDF export. Read-only: it adds no tables and changes no existing models. + +> Modelled on the Day-End report in the other HexDrive ERP, but adapted to this project's data. +> The big difference: **there is no cash/card split here.** `PaymentMethod` is `Cash | Cheque | Credit`, +> so the report splits collections by those three. + +--- + +## 1. What the report shows (one selected day) + +| Group | Figures | +|---|---| +| Sales | Invoiced total (finalized invoices that day), Collected, Due | +| Collections by method | Cash, Cheque, Credit (credit = extended to the store, not money received) | +| Counts | Invoices count, payments count, count by method | +| Returns | Total credit amount of sale returns that day (`SaleReturn.TotalCreditAmount`) | +| Expenses | Total + list of that day's expenses | +| Result | **Income of the day** | +| Secondary | Cash in hand movement: cash collected − cash expenses paid | + +Plus: +- Day-wise income **chart** over a date range (Sales / Expenses / Income). +- Day-wise table with a totals row. +- **PDF export** (single day, and day-wise range). + +--- + +## 2. What already exists (reused, nothing new in the DB) + +| Need | Source | +|---|---| +| Sales | `Invoice` — `Status = Finalized`, `FinalizedAt`, `TotalAmount`, `IsActive`, excluding `ShopPO.Status = Cancelled` | +| Collections | `Payment` — `Amount`, `Method`, `CollectedAt`, `FK_BankAccountId` | +| Returns | `SaleReturn` — `TotalCreditAmount` (ItemExchange is 0), `CreatedAt` | +| Expenses | `Expense` — `Amount`, `BillDate`, `PaidAt`, `Status` (`Draft/Posted/Paid/Void`), `Method` | +| Region scope | `Invoice → ShopPO → Store → Route.FK_RegionId` (same filter `PaymentReport` uses) | +| Backend pattern | `Features/PaymentReport/*` (controller + service + repository + DTOs) | +| Frontend | `lib/api/reports.ts`, `config/nav.ts`, `recharts`, `jspdf` + `jspdf-autotable` (all already installed) | + +--- + +## 3. Backend — new `Features/DayEndReport/` + +Same shape as `PaymentReport`: + +- `DayEndReportController.cs` +- `DayEndReportService.cs` +- `IDayEndReportRepository.cs` / `DayEndReportRepository.cs` +- `DayEndReportDTOs.cs` +- Register repo + service in `Program.cs` next to the `PaymentReport` lines. + +### 3.1 `GET /api/reports/day-end?date=YYYY-MM-DD®ionId=` + +Response (one day): + +| Section | Fields | +|---|---| +| Sales | `TotalSales`, `Collected`, `Due` | +| By method | `Cash`, `Cheque`, `Credit` (amount + count each) | +| Counts | `InvoiceCount`, `PaymentCount` | +| Returns | `SalesReturns` | +| Expenses | `ExpenseTotal`, `Expenses[]` (doc no, category, title, payee, method, amount) | +| Income | `Income = TotalSales − SalesReturns − ExpenseTotal` | +| Cash in hand | `CashCollected`, `CashExpensesPaid`, `NetCash` | + +### 3.2 `GET /api/reports/day-end/daily?from=&to=®ionId=` + +One row per day: `Date, TotalSales, Collected, Due, Cash, Cheque, Credit, Returns, Expenses, Income` +plus a totals row. Feeds the chart, the table and the PDF. + +- Days with no activity are returned as zeros. +- Range capped at 92 days (matches `PaymentReport`'s daily granularity; error message in the same style). + +### 3.3 Rules (need confirming — see §6) + +- **Sales** = finalized invoices by `FinalizedAt`, active, order not cancelled. +- **Collected** = payments by `CollectedAt` (includes instalments against older invoices). +- **Due** = that day's invoiced total − payments received **on the same invoices** that day? or running balance? → decision #2. +- **Expenses** = `Posted` and `Paid` expenses, `Void` and `Draft` excluded. Dated by `BillDate`. +- **Region filter** optional; unset = all regions. +- **Day boundaries**: use the same local-date handling `PaymentReport` uses (`DateTime.Now.Date`, `[day, day+1)`), so numbers agree with the Payment Analytics page. +- Permission: a `reports.day-end` view permission, seeded like the other permissions (to confirm the existing seeding pattern when implementing). + +--- + +## 4. Frontend — `/reports/day-end` + +- API: add `dayEnd` calls + types to `lib/api/reports.ts`. +- Page `app/(dashboard)/reports/day-end/page.tsx`: + - Toolbar: date picker (default today), region select, **Download PDF**. + - Summary cards: Total Sales · Collected · Due · Cash · Cheque · Credit · Returns · Expenses · **Income** (green/red by sign). + - Expenses table for the selected day. + - Chart: from/to (default last 30 days), Sales / Expenses / Income lines (`recharts`), daily table below. +- Nav: add "Day-End Sales" entry in `config/nav.ts` (Reports group), gated by the permission. + +## 5. PDF export + +- `lib/pdf/day-end-report-pdf.ts` using `jspdf` + `jspdf-autotable`. +- Page 1: header (company, region, range, generated at), period totals, chart image. +- Following pages: day-wise table with totals row. +- Single-day variant: summary figures + expense list. + +--- + +## 6. Decisions I need from you + +| # | Question | My default | +|---|---|---| +| 1 | **Income basis.** Accrual (sales − returns − expenses dated that day) or cash basis (money collected − expenses paid that day)? | Accrual; cash shown as secondary `NetCash`. | +| 2 | **"Due" meaning.** Unpaid part of that day's invoices, or the running outstanding balance? | Unpaid part of that day's invoices. | +| 3 | **Credit method.** Count `Credit` payments as collected? | No — shown separately, not in "Collected" (same as Payment Analytics). | +| 4 | **Card.** You asked for cash vs card in the other project; here only Cash/Cheque/Credit exist. Add a Card method (schema change + UI) or just report what exists? | Report what exists; no schema change. | +| 5 | **Expense date.** `BillDate` or `PaidAt`? | `BillDate`. | + +## 7. Verification + +- Backend tests (in `Distribution.Tests`): mixed methods, partial payments, cancelled order excluded, void/draft expense excluded, instalment on an older invoice, day-boundary case, empty days. +- Cross-check: the "Collected" total for a range equals Payment Analytics' collected for the same range. +- Frontend: cards match the daily table; PDF paginates over a 92-day range. + +--- + +## 8. Implemented (2026-10-23) — differences from the plan + +Built with all five recommended defaults. + +- **No region filter and no new permission.** Expenses are attributed to a warehouse, not a region, so a region filter would make income inconsistent. The endpoints follow `PaymentReport` (any authenticated user); the nav entry reuses `warehouses.view` like the other reports. +- **Endpoints:** `GET /api/reports/day-end?date=` and `GET /api/reports/day-end/daily?from=&to=`. +- **PDF:** reuses `exportPdf` from `lib/export.ts` (day-wise table with totals row). No chart image in the PDF. +- **Tests:** `Distribution.Tests/Services/DayEndReportServiceTests.cs` (12 passing). The older test files in that project (`OrderServiceTests`, `StoreServiceTests`, `AuthServiceTests`, `ItemServiceTests`, `RouteServiceTests`, `RegionServiceTests`) no longer compile, so the project can't build until they are fixed. Not touched here. -- 2.52.0 From e175a7a040c0f3418f2464c862921e25e02f2422 Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Fri, 23 Oct 2026 14:54:40 +0530 Subject: [PATCH 3/3] 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), +} -- 2.52.0