From f4ec3b4c650143180b32192e7330c099c56ba204 Mon Sep 17 00:00:00 2001 From: Sasanka20 Date: Mon, 5 Oct 2026 13:11:01 +0530 Subject: [PATCH] fixers of day end sales and sales --- .../Controllers/CustomersController.cs | 5 +- .../Controllers/SalesInvoicesController.cs | 4 +- .../ERPCore/Dtos/Sales/SalesInvoiceDtos.cs | 2 +- Backend/ERPCore/Services/CustomerService.cs | 18 +- .../Services/Interfaces/ICustomerService.cs | 2 +- .../Interfaces/ISalesInvoiceService.cs | 2 +- .../ERPCore/Services/SalesInvoiceService.cs | 9 +- .../app/dashboard/customers/page.tsx | 9 +- .../app/dashboard/sales/day-end/page.tsx | 555 +++++++++++++++++- .../dashboard/sales/invoices/[id]/page.tsx | 398 +++++++------ .../app/dashboard/sales/invoices/new/page.tsx | 53 +- .../app/dashboard/sales/invoices/page.tsx | 433 +++++--------- Frontend/erp-system/lib/api/customers.ts | 3 + Frontend/erp-system/lib/api/sales.ts | 6 + Frontend/erp-system/types/sales.ts | 2 + 15 files changed, 1000 insertions(+), 501 deletions(-) diff --git a/Backend/ERPCore/Controllers/CustomersController.cs b/Backend/ERPCore/Controllers/CustomersController.cs index 83c40e4..aa6f146 100644 --- a/Backend/ERPCore/Controllers/CustomersController.cs +++ b/Backend/ERPCore/Controllers/CustomersController.cs @@ -24,8 +24,9 @@ public sealed class CustomersController : ApiControllerBase [FromQuery] EntityStatus? status, [FromQuery] CustomerType? customerType, CancellationToken ct, - [FromQuery] bool includeCashCustomer = false) - => Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct)); + [FromQuery] bool includeCashCustomer = false, + [FromQuery] int? branchId = null) + => Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct)); [HttpGet("{customerId:int}")] [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs index f79919a..50c947e 100644 --- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs +++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs @@ -90,8 +90,10 @@ public sealed class SalesInvoicesController : ApiControllerBase [FromQuery] int? branchId, [FromQuery] DateOnly? from, [FromQuery] DateOnly? to, + [FromQuery] int? createdBy, + [FromQuery] bool? paid, CancellationToken ct) - => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct)); + => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); [HttpGet("{salesInvoiceId:int}")] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index 92f674b..302b04f 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -23,7 +23,7 @@ public sealed record SalesInvoiceDto( public sealed record SalesInvoiceSummaryDto( int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType, - SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo); + SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo, int CreatedBy); public sealed record SalesInvoicePaymentDto( int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate, diff --git a/Backend/ERPCore/Services/CustomerService.cs b/Backend/ERPCore/Services/CustomerService.cs index 510f386..a67c204 100644 --- a/Backend/ERPCore/Services/CustomerService.cs +++ b/Backend/ERPCore/Services/CustomerService.cs @@ -34,19 +34,23 @@ public sealed class CustomerService : ICustomerService } public async Task> ListAsync( - PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default) + PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default) { var q = _customers.Query().AsNoTracking(); + var cashCustomerId = await _salesConfig.Query().AsNoTracking() + .Where(c => c.ConfigId == SalesConfig.SingletonId) + .Select(c => c.CashCustomerId) + .FirstOrDefaultAsync(ct); // The generic cash customer is a system default for walk-in / one-time buyers, not a registered // customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names). if (!includeCashCustomer) - { - var cashCustomerId = await _salesConfig.Query().AsNoTracking() - .Where(c => c.ConfigId == SalesConfig.SingletonId) - .Select(c => c.CashCustomerId) - .FirstOrDefaultAsync(ct); q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId)); - } + // Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created + // under. The cash customer is shared by every branch, so it is never filtered out here. + if (branchId is not null) + q = q.Where(c => c.BranchId == branchId + || c.CustomerCode == CashCustomerCode + || (cashCustomerId != null && c.CustomerId == cashCustomerId)); if (!string.IsNullOrWhiteSpace(query.Q)) { var term = query.Q.Trim(); diff --git a/Backend/ERPCore/Services/Interfaces/ICustomerService.cs b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs index 5640b57..91a76fe 100644 --- a/Backend/ERPCore/Services/Interfaces/ICustomerService.cs +++ b/Backend/ERPCore/Services/Interfaces/ICustomerService.cs @@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces; public interface ICustomerService { /// Registered customers; the system cash customer is left out unless . - Task> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default); + Task> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default); Task?> GetAsync(int customerId, CancellationToken ct = default); Task> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default); Task> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs index dcdacc7..8bbca39 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs @@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces; public interface ISalesInvoiceService { - Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default); + Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index fd1db63..4ca9888 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService _uow = uow; } - public async Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default) + public async Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) { IQueryable q = _invoices.Query().AsNoTracking().Include(x => x.Lines); if (!string.IsNullOrWhiteSpace(query.Q)) @@ -67,6 +67,11 @@ public sealed class SalesInvoiceService : ISalesInvoiceService // Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService. if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue)); if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue)); + // Invoices have no CashierUserId — Day End matches them to a cashier by CreatedBy. + if (createdBy is not null) q = q.Where(x => x.CreatedBy == createdBy); + // paid=true: nothing left to pay; paid=false: a balance is still due. + if (paid == true) q = q.Where(x => x.BalanceAmount <= 0); + if (paid == false) q = q.Where(x => x.BalanceAmount > 0); var total = await q.CountAsync(ct); var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); @@ -224,5 +229,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new( x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, - x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo); + x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo, x.CreatedBy); } diff --git a/Frontend/erp-system/app/dashboard/customers/page.tsx b/Frontend/erp-system/app/dashboard/customers/page.tsx index 4c8fc16..235ea61 100644 --- a/Frontend/erp-system/app/dashboard/customers/page.tsx +++ b/Frontend/erp-system/app/dashboard/customers/page.tsx @@ -81,7 +81,11 @@ export default function CustomersPage() { return () => clearTimeout(timeout) }, [searchInput]) - useEffect(() => setPage(1), [query, status, customerType]) + // Branch-wise list, same as Sales / GRN: the header's active branch (a non-admin's own branch); + // an Admin on "All Branches" sees every branch's customers. + const listBranchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined + + useEffect(() => setPage(1), [query, status, customerType, listBranchId]) function load() { setError(null) @@ -90,6 +94,7 @@ export default function CustomersPage() { q: query || undefined, status: status === "All" ? undefined : status, customerType: customerType === "All" ? undefined : customerType, + branchId: listBranchId, page, pageSize, }) @@ -106,7 +111,7 @@ export default function CustomersPage() { setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) } - useEffect(load, [query, status, customerType, page, pageSize]) + useEffect(load, [query, status, customerType, listBranchId, page, pageSize]) useEffect(() => { // Every code in use, the hidden cash customer's included, so a generated code never collides. diff --git a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx index 519742c..5c698f4 100644 --- a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx @@ -2,18 +2,27 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { AlertTriangle, ArrowLeft, CheckCircle2, Lock, ReceiptText } from "lucide-react" +import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react" +import { salesApi } from "@/lib/api/sales" +import { salesConfigApi } from "@/lib/api/sales-config" import { salesDayEndApi } from "@/lib/api/sales-day-end" import { usersApi } from "@/lib/api/users" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" +import { PaginationMeta } from "@/types/common" import { ManagedUser } from "@/types/users" -import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview } from "@/types/sales" +import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales" +import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog" +import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" +import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint" +import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges" import { Button, buttonVariants } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -23,6 +32,51 @@ function todayIso(): string { return new Date().toISOString().slice(0, 10) } +type SalesDateRange = "All" | "Today" | "7d" | "30d" + +const salesDateRanges: { value: SalesDateRange; label: string; days: number | null }[] = [ + { value: "All", label: "All time", days: null }, + { value: "Today", label: "Today", days: 1 }, + { value: "7d", label: "7 days", days: 7 }, + { value: "30d", label: "30 days", days: 30 }, +] + +/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */ +function toLocalIsoDate(d: Date) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` +} + +/** Inclusive range ending today ("7 days" is today plus the 6 days before it) — same as the Sales page. */ +function salesDateBounds(range: SalesDateRange): { from?: string; to?: string } { + const days = salesDateRanges.find((r) => r.value === range)?.days + if (!days) return {} + const today = new Date() + const from = new Date(today) + from.setDate(today.getDate() - (days - 1)) + return { from: toLocalIsoDate(from), to: toLocalIsoDate(today) } +} + +/** Past its due date (`invoiceDate`) with money still owed — compared by calendar day, so an + * invoice due today is not overdue. Same rule as the Sales page. */ +function isOverdue(row: SalesInvoiceSummary): boolean { + if (row.status !== "Posted" || row.totals.balanceAmount <= 0) return false + const due = new Date(row.invoiceDate) + const today = new Date() + today.setHours(0, 0, 0, 0) + due.setHours(0, 0, 0, 0) + return due < today +} + +const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20] + +type PaymentFilter = "All" | "Unpaid" | "Paid" + +const paymentFilters: { value: PaymentFilter; label: string }[] = [ + { value: "All", label: "All payments" }, + { value: "Unpaid", label: "Balance due" }, + { value: "Paid", label: "Fully paid" }, +] + export default function SalesDayEndPage() { const [cashiers, setCashiers] = useState([]) const [cashierUserId, setCashierUserId] = useState(null) @@ -35,6 +89,49 @@ export default function SalesDayEndPage() { const [report, setReport] = useState(null) const [closing, setClosing] = useState(false) + // Approved (Posted) sales invoices, shown as a table with its own filters (independent of the + // business date above): a cashier (null follows the cashier picked above, "All" shows every + // cashier's sales) and a date range. + const [salesCashier, setSalesCashier] = useState(null) + const effectiveSalesCashier = salesCashier ?? cashierUserId + const [salesDateRange, setSalesDateRange] = useState("Today") + const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange) + const [salesPayment, setSalesPayment] = useState("All") + const [salesSearchInput, setSalesSearchInput] = useState("") + const [salesQuery, setSalesQuery] = useState("") + // Server-side paging — every filter above is applied by the API, so pages and counts are exact. + const [salesPage, setSalesPage] = useState(1) + const [salesPageSize, setSalesPageSize] = useState(SALES_PAGE_SIZE_OPTIONS[1]) + const [salesReload, setSalesReload] = useState(0) + // Results are tagged with the request they answer; a stale or pending one reads as "loading". + const salesKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize, salesReload].join("|") + const [salesResult, setSalesResult] = useState<{ + key: string + rows: SalesInvoiceSummary[] | null + pagination: PaginationMeta | null + error: string | null + } | null>(null) + const current = salesResult?.key === salesKey ? salesResult : null + const sales = current?.rows ?? null + const salesPagination = current?.pagination ?? null + const salesError = current?.error ?? null + const [viewInvoice, setViewInvoice] = useState(null) + // Read through the loaded rows so the pop-up's Pay button follows payments. + const viewRow = viewInvoice ? (sales?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null + + // Invoice payments are taken here, not on the Sales screens. One invoice at a time via Pay, or + // several of one customer ticked together — the generic cash customer's are paid one at a time. + const [payInvoice, setPayInvoice] = useState(null) + const [payOpen, setPayOpen] = useState(false) + const [selected, setSelected] = useState>(new Map()) + const [bulkPayOpen, setBulkPayOpen] = useState(false) + const [cashCustomerId, setCashCustomerId] = useState(null) + + useEffect(() => { + // A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway. + salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null)) + }, []) + useEffect(() => { usersApi .list({ pageSize: 200 }) @@ -62,6 +159,71 @@ export default function SalesDayEndPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [cashierUserId, businessDate]) + // Debounced search; a new term starts again from page 1. + useEffect(() => { + const timeout = setTimeout(() => { + setSalesQuery(salesSearchInput.trim()) + setSalesPage(1) + setSelected(new Map()) + }, 300) + return () => clearTimeout(timeout) + }, [salesSearchInput]) + + useEffect(() => { + if (effectiveSalesCashier === null) return + salesApi + .listInvoices({ + page: salesPage, + pageSize: salesPageSize, + status: "Posted", + createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier, + from: salesFrom, + to: salesTo, + paid: salesPayment === "All" ? undefined : salesPayment === "Paid", + q: salesQuery || undefined, + }) + .then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null })) + .catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) })) + }, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize]) + + /** Re-read the table (after a payment); ticks are cleared since balances changed. */ + function reloadSales() { + setSelected(new Map()) + setSalesReload((n) => n + 1) + } + + // Any change to what the table lists goes back to page 1 and clears the ticks. + function resetSalesView() { + setSalesPage(1) + setSelected(new Map()) + } + + function changeCashier(userId: number | null) { + setCashierUserId(userId) + setSalesCashier(null) + resetSalesView() + } + + function changeSalesCashier(value: number | "All") { + setSalesCashier(value) + resetSalesView() + } + + function changeSalesDateRange(value: SalesDateRange) { + setSalesDateRange(value) + resetSalesView() + } + + function changeSalesPayment(value: PaymentFilter) { + setSalesPayment(value) + resetSalesView() + } + + function changeSalesPageSize(size: number) { + setSalesPageSize(size) + resetSalesView() + } + async function handleClose() { if (!cashierUserId) return setClosing(true) @@ -79,6 +241,26 @@ export default function SalesDayEndPage() { const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName + const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null + + /** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */ + function notSelectableReason(row: SalesInvoiceSummary): string | null { + if (selected.has(row.salesInvoiceId)) return null + if (row.totals.balanceAmount <= 0) return "Already fully paid" + if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time" + if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only" + return null + } + + function toggleSelected(row: SalesInvoiceSummary) { + setSelected((prev) => { + const next = new Map(prev) + if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId) + else next.set(row.salesInvoiceId, row) + return next + }) + } + return (
@@ -96,7 +278,7 @@ export default function SalesDayEndPage() {
- value={cashierUserId} onValueChange={setCashierUserId}> + value={cashierUserId} onValueChange={changeCashier}> @@ -294,6 +476,373 @@ export default function SalesDayEndPage() {
)} + + { + setPayInvoice(row) + setPayOpen(true) + }} + selected={selected} + notSelectableReason={notSelectableReason} + onToggle={toggleSelected} + onClearSelection={() => setSelected(new Map())} + onPaySelected={() => setBulkPayOpen(true)} + /> + + {/* The invoice pop-up (View action or row click). Its Pay button follows the reloaded row, + and the sheet itself re-reads after a payment via refreshKey. */} + { if (!open) setViewInvoice(null) }} + refreshKey={salesReload} + actions={ + viewRow && viewRow.totals.balanceAmount > 0 ? ( + + ) : null + } + /> + {/* After a payment the table is re-read, so Paid / Remaining come from the server. */} + + {}} + onPaid={reloadSales} + /> +
+ ) +} + +// One page of Approved (Posted) sales invoices. Every filter (cashier or all cashiers, date range, +// payment status, search) is applied server-side, so the pager and counts cover all matches. +function SalesTable({ + rows, + error, + cashiers, + cashierFilter, + onCashierFilterChange, + dateRange, + onDateRangeChange, + paymentFilter, + onPaymentFilterChange, + search, + onSearchChange, + pagination, + onPageChange, + onPageSizeChange, + onView, + onPay, + selected, + notSelectableReason, + onToggle, + onClearSelection, + onPaySelected, +}: { + rows: SalesInvoiceSummary[] | null + error: string | null + cashiers: ManagedUser[] + cashierFilter: number | "All" | null + onCashierFilterChange: (value: number | "All") => void + dateRange: SalesDateRange + onDateRangeChange: (value: SalesDateRange) => void + paymentFilter: PaymentFilter + onPaymentFilterChange: (value: PaymentFilter) => void + search: string + onSearchChange: (value: string) => void + pagination: PaginationMeta | null + onPageChange: (page: number) => void + onPageSizeChange: (size: number) => void + onView: (row: SalesInvoiceSummary) => void + onPay: (row: SalesInvoiceSummary) => void + selected: Map + notSelectableReason: (row: SalesInvoiceSummary) => string | null + onToggle: (row: SalesInvoiceSummary) => void + onClearSelection: () => void + onPaySelected: () => void +}) { + const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0) + + function renderSelectBox(row: SalesInvoiceSummary) { + const reason = notSelectableReason(row) + return ( + onToggle(row)} + /> + ) + } + + // View opens the invoice pop-up; Pay shows while a balance is still due. + function renderActions(row: SalesInvoiceSummary) { + return ( +
+ + {row.totals.balanceAmount > 0 && ( + + )} +
+ ) + } + + const showCashier = cashierFilter === "All" + const cashierNameById = new Map(cashiers.map((c) => [c.userId, c.displayName])) + const visible = rows ?? [] + const filtered = paymentFilter !== "All" || search.trim().length > 0 + + const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0) + const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0) + const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0) + + return ( +
+
+

Sales

+

Approved sales invoices — filter by cashier, date, payment status or search.

+
+ +
+ + value={cashierFilter} + onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }} + items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]} + > + + + + + All cashiers + {cashiers.map((c) => ( + {c.displayName} + ))} + + + + value={dateRange} + onValueChange={(v) => onDateRangeChange(v ?? "Today")} + items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))} + > + + + + + + {salesDateRanges.map((r) => ( + {r.label} + ))} + + + + value={paymentFilter} + onValueChange={(v) => onPaymentFilterChange(v ?? "All")} + items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))} + > + + + + + {paymentFilters.map((p) => ( + {p.label} + ))} + + + onSearchChange(e.target.value)} + placeholder="Search invoice or customer" + className="h-10 text-sm" + /> +
+ + {error &&
{error}
} + + {!error && rows === null && } + + {!error && rows !== null && visible.length === 0 && ( +

+ {filtered ? "No sales match these filters." : "No approved sales in this period."} +

+ )} + + {selected.size > 0 && ( +
+

+ {selected.size} invoice{selected.size === 1 ? "" : "s"} selected for{" "} + {selected.values().next().value!.customerSnapshotName} — balance{" "} + {selectedBalance.toFixed(2)} +

+
+ + +
+
+ )} + + {!error && rows !== null && visible.length > 0 && ( + <> + {/* Mobile: stacked rows. */} +
+ {visible.map((row) => ( +
+ {renderSelectBox(row)} + + {renderActions(row)} +
+ ))} +
+ + {/* Desktop: table. */} +
+ + + + + Invoice + Customer + {showCashier && Cashier} + Date + Due + Net + Paid + Remaining + Status + Actions + + + + {visible.map((row) => ( + { + if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return + onView(row) + }} + > + + {renderSelectBox(row)} + + {row.invoiceNo} + {row.customerSnapshotName} + {showCashier && ( + {cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`} + )} + {new Date(row.createdAt).toLocaleDateString()} + + {new Date(row.invoiceDate).toLocaleDateString()} + + {row.totals.grandTotal.toFixed(2)} + {row.totals.paidAmount.toFixed(2)} + 0 && "font-semibold text-destructive" + )} + > + {row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)} + + +
+ + {row.status !== "Draft" && ( + + )} +
+
+ + {renderActions(row)} + +
+ ))} +
+
+
+ + {pagination && ( + + )} + + {/* Invoices counts every match; the amounts are for the rows on this page. */} +
+ + + + +
+ + )}
) } diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx index d32400a..76293e0 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -58,6 +58,22 @@ const blankLine = (key: string): Line => ({ parentLineId: null, }) +/** Comparable form of the draft's editable fields (line keys excluded) — for "unsaved changes". */ +function draftSnapshot(customerId: number | null, warehouseId: number | null, invoiceType: SalesInvoiceType, lines: Line[]): string { + return JSON.stringify({ + customerId, + warehouseId, + invoiceType, + lines: lines.map((line) => ({ + itemId: line.itemId, + warehouseId: line.warehouseId, + qty: Number(line.qty), + freeQty: Number(line.freeQty), + unitPrice: line.unitPrice ?? null, + })), + }) +} + /** "Posted" reads as "approved" everywhere in the UI — matches SalesInvoiceStatusBadge's label. */ function statusWord(status: SalesInvoiceStatus): string { return status === "Posted" ? "approved" : status.toLowerCase() @@ -77,6 +93,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i const [company, setCompany] = useState(null) const [template, setTemplate] = useState(null) const [lines, setLines] = useState([blankLine("line-1")]) + // The draft as last loaded/saved — Save as Draft is enabled only when the form differs from it. + const [savedSnapshot, setSavedSnapshot] = useState(null) const [customerId, setCustomerId] = useState(null) const [warehouseId, setWarehouseId] = useState(null) const [invoiceType, setInvoiceType] = useState("B2C") @@ -95,6 +113,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i // editor's Warehouse picker with a locked field — the server keeps the invoice on it regardless. const [allowWarehouseSelection, setAllowWarehouseSelection] = useState(true) const [defaultWarehouseId, setDefaultWarehouseId] = useState(null) + // The shared cash customer — offered on every branch's Bill To picker. + const [cashCustomerId, setCashCustomerId] = useState(null) useEffect(() => { if (!Number.isFinite(invoiceId)) { @@ -118,6 +138,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i setAllowDiscounts(config?.allowDiscounts ?? true) setAllowWarehouseSelection(!config?.defaultWarehouseId && (config?.allowWarehouseSelection ?? true)) setDefaultWarehouseId(config?.defaultWarehouseId ?? null) + setCashCustomerId(config?.cashCustomerId ?? null) setCustomers(customerRes.items) setItems(itemRes.items) setUoms(uomRes.items) @@ -129,24 +150,24 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i setCustomerId(doc.data.customerId) setWarehouseId(doc.data.warehouseId) setInvoiceType(doc.data.invoiceType) - setLines( - doc.data.lines.map((line) => ({ - key: String(line.salesInvoiceLineId), - itemId: line.itemId, - warehouseId: line.warehouseId, - qty: line.qty, - freeQty: line.freeQty, - unitPrice: line.unitPrice, - allowManualPriceOverride: true, - discountMode: line.discountMode, - discountPct: line.discountPct, - discountAmount: line.discountAmount, - discountValue: 0, - taxPct: line.taxPct, - isFreeIssue: line.isFreeIssue, - parentLineId: line.parentLineId, - })) - ) + const loadedLines: Line[] = doc.data.lines.map((line) => ({ + key: String(line.salesInvoiceLineId), + itemId: line.itemId, + warehouseId: line.warehouseId, + qty: line.qty, + freeQty: line.freeQty, + unitPrice: line.unitPrice, + allowManualPriceOverride: true, + discountMode: line.discountMode, + discountPct: line.discountPct, + discountAmount: line.discountAmount, + discountValue: 0, + taxPct: line.taxPct, + isFreeIssue: line.isFreeIssue, + parentLineId: line.parentLineId, + })) + setLines(loadedLines) + setSavedSnapshot(draftSnapshot(doc.data.customerId, doc.data.warehouseId, doc.data.invoiceType, loadedLines)) }) .catch((err) => setError(errorMessage(err))) }, [invoiceId, resolvedParams.id]) @@ -184,6 +205,21 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i } const isDraft = invoice?.status === "Draft" + // Branch-wise Bill To picker: customers of the invoice's own branch, plus the shared cash customer + // and whoever the draft is already billed to (so an older cross-branch draft still shows its customer). + const branchCustomers = useMemo( + () => + customers.filter( + (c) => + !invoice || + c.branchId === invoice.branchId || + c.customerId === cashCustomerId || + c.customerCode === "CUST-CASH" || + c.customerId === invoice.customerId, + ), + [customers, invoice, cashCustomerId], + ) + const isDirty = savedSnapshot !== null && draftSnapshot(customerId, warehouseId, invoiceType, lines) !== savedSnapshot const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId]) const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId]) // With selection off, saving moves the draft onto the default sales warehouse (server-forced), @@ -241,11 +277,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key))) } - async function save() { - if (!customerId || !warehouseId || !etag || !invoice) return + /** Saves the draft's edits; true when they were stored. `quiet` skips the toast (Approve's first step). */ + async function save(quiet = false): Promise { + if (!customerId || !warehouseId || !etag || !invoice) return false if (lines.some((line) => !line.itemId || !line.warehouseId)) { setError("Select item, UOM and warehouse for every line.") - return + return false } setSaving(true) @@ -278,25 +315,37 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i ) setInvoice(updated.data) setEtag(updated.etag) - toast.success("Invoice saved", updated.data.invoiceNo) + setSavedSnapshot(draftSnapshot(customerId, warehouseId, invoiceType, lines)) + if (!quiet) { + // Save as Draft is done with the invoice — back to the Sales list (drafts table). + toast.success("Draft saved", updated.data.invoiceNo) + router.push("/dashboard/sales/invoices") + } + return true } catch (err) { setError(errorMessage(err)) + return false } finally { setSaving(false) } } + // Approve = save the draft's current edits, then post it. Only an approved invoice takes payments. async function post() { if (!postingCheck?.canPost) { - setError("Resolve stock shortages before posting this invoice.") + setError("Resolve stock shortages before approving this invoice.") return } setBusy("post") + if (isDirty && !(await save(true))) { + setBusy(null) + return + } setError(null) try { const posted = await salesApi.postInvoice(invoiceId) setInvoice(posted) - toast.success("Invoice posted", posted.invoiceNo) + toast.success("Invoice approved", posted.invoiceNo) router.refresh() } catch (err) { setError(errorMessage(err)) @@ -387,7 +436,64 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
)} -
+ {/* A draft reopens in edit mode right here in the invoice body: header fields and lines are + editable in place; Save as Draft / Approve invoice sit under Actions. */} + {isDraft ? ( +
+
+ + +
+
+ + {allowWarehouseSelection ? ( + + ) : ( + // Pinned to the default sales warehouse — shown, not editable. +
+ {pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"} +
+ )} +
+
+ + +
+
+ ) : null} + +
Bill To
{invoice.customerSnapshotName}
@@ -401,7 +507,91 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
-
+ {isDraft ? ( +
+ addItem(Number(v))} + placeholder="Add item — type SKU or name…" + emptyText="No items match." + className="mb-3 w-full sm:max-w-md" + aria-label="Add item" + /> +
+ + + + + + + {allowFreeItems && } + + + + + + {lines.map((line) => ( + + + + + {allowFreeItems && ( + + )} + + + + ))} + +
ItemUOMQtyFreeUnit priceRemove
+ {itemLabel(line.itemId)} + {baseUomLabel(items, uoms, line.itemId)} + updateLine(line.key, { qty: Number(e.target.value) || 0 })} + aria-label="Qty" + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums" + /> + + updateLine(line.key, { freeQty: Number(e.target.value) || 0 })} + aria-label="Free qty" + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums" + /> + + updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })} + aria-label="Unit price" + className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums" + /> + + +
+
+

Totals below update when the draft is saved.

+
+ ) : null} + +
@@ -529,23 +719,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i

Actions

- {isDraft ? "Draft invoice can be edited." : "Only draft invoices are editable."} + + {isDraft ? "Edit the draft above, then save as draft or approve. Payments open once approved." : "Only draft invoices are editable."} +
{isDraft ? ( <> - addItem(Number(v))} - placeholder="Add item — type SKU or name…" - emptyText="No items match." - className="mb-4 w-full sm:max-w-md" - aria-label="Add item" - />
- {postingCheck && !postingCheck.canPost ? ( - {postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"} + {postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Approving..." : "Approve invoice"}
- -
-
- - - - - - {allowFreeItems && } - - - - - - {lines.map((line) => ( - - - - - {allowFreeItems && ( - - )} - - - - ))} - -
ItemUOMQtyFreeUnit priceRemove
- {itemLabel(line.itemId)} - - {baseUomLabel(items, uoms, line.itemId)} - - updateLine(line.key, { qty: Number(e.target.value) || 0 })} - className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" - /> - - updateLine(line.key, { freeQty: Number(e.target.value) || 0 })} - className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" - /> - - updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })} - className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm" - /> -
- {getSuggestedUnitPrice(items, line.itemId) !== null - ? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}` - : "No price suggestion available"} -
-
- -
-
- -
-
- - -
-
- - {allowWarehouseSelection ? ( - - ) : ( - // Pinned to the default sales warehouse — shown, not editable. -
- {pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"} -
- )} -
-
- - -
-
-
- -
) : (
diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx index b3b6d70..4723e4a 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/new/page.tsx @@ -137,7 +137,8 @@ function NewSalesInvoiceContent() { const [bundleTemplates, setBundleTemplates] = useState([]) const [loading, setLoading] = useState(true) const [submitError, setSubmitError] = useState(null) - const [saving, setSaving] = useState(false) + // Which action is in flight — Save as Draft or Approve invoice. + const [saving, setSaving] = useState<"draft" | "approve" | null>(null) const [scanError, setScanError] = useState(null) // Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price. const [resolvedPrices, setResolvedPrices] = useState>({}) @@ -159,9 +160,16 @@ function NewSalesInvoiceContent() { const w = id === null ? undefined : warehouses.find((x) => x.warehouseId === id) return w ? `${w.code} — ${w.name}` : null } + // The sale's branch (see the branch picker below). Declared here so the customer list can use it. + const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null + // Branch-wise, like the warehouses below: only customers created under the sale's branch. The + // cash customer is shared by every branch and is added to the dropdown separately. const regularCustomers = useMemo( - () => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId), - [customers, salesConfig], + () => + customers.filter( + (c) => c.customerId !== salesConfig?.cashCustomerId && (effectiveBranchId === null || c.branchId === effectiveBranchId), + ), + [customers, salesConfig, effectiveBranchId], ) // The dropdown rows carry extra customer details, so pass explicit trigger labels — otherwise // the Select wrapper derives them from the rows and the closed trigger shows the details too. @@ -247,7 +255,6 @@ function NewSalesInvoiceContent() { // branch to pin to and the Admin must pick one explicitly for this sale. const lockedBranchId = isAdmin ? activeBranchId : myBranchId const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId - const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null const visibleWarehouses = useMemo( () => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)), [warehouses, effectiveBranchId] @@ -262,6 +269,12 @@ function NewSalesInvoiceContent() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [visibleWarehouses]) + // Same for the customer: one from another branch is cleared when the branch changes (the + // shared cash customer always stays valid). + const customerOutOfBranch = + customerId !== null && customerId !== cashCustomerId && customers.length > 0 && !regularCustomers.some((c) => c.customerId === customerId) + if (customerOutOfBranch) setCustomerId(null) + function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null { return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null } @@ -667,7 +680,9 @@ function NewSalesInvoiceContent() { const netTotal = Math.max(0, grossTotal - discountTotal) const payableTotal = netTotal + taxTotal - async function submit() { + /** "draft" saves the sale as a Draft (still editable); "approve" saves it and approves (posts) it + * straight away — only an approved invoice can take payments. */ + async function submit(mode: "draft" | "approve") { if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.") if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.") if (lines.length === 0) return setSubmitError("Add at least one item.") @@ -701,12 +716,25 @@ function NewSalesInvoiceContent() { })), } - setSaving(true) + setSaving(mode) setSubmitError(null) try { const created = await salesApi.createInvoice(payload) - toast.success("Invoice created", created.data.invoiceNo) - router.push(`/dashboard/sales/invoices/${created.data.salesInvoiceId}`) + const detailUrl = `/dashboard/sales/invoices/${created.data.salesInvoiceId}` + if (mode === "approve") { + try { + const posted = await salesApi.postInvoice(created.data.salesInvoiceId) + toast.success("Invoice approved", posted.invoiceNo) + } catch (err) { + // The sale is saved — it just stays a Draft; its page shows why and can approve it later. + toast.error(`Saved as draft ${created.data.invoiceNo} — could not approve`, errorMessage(err)) + } + router.push(detailUrl) + } else { + // Save as Draft goes straight back to the Sales list (drafts table). + toast.success("Draft saved", created.data.invoiceNo) + router.push("/dashboard/sales/invoices") + } } catch (err) { if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) { router.push(`/login?next=/dashboard/sales/invoices/new`) @@ -714,7 +742,7 @@ function NewSalesInvoiceContent() { } setSubmitError(errorMessage(err)) } finally { - setSaving(false) + setSaving(null) } } @@ -1278,8 +1306,11 @@ function NewSalesInvoiceContent() { Cancel - +
diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx index d5e07f1..9153a57 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from "react" import Link from "next/link" +import { useRouter } from "next/navigation" import { - AlertTriangle, ArrowDown, ArrowUp, ArrowUpDown, @@ -11,23 +11,20 @@ import { CheckCircle2, CircleDollarSign, FileText, - ListFilter, Loader2, Plus, - Printer, Receipt, Tag, } from "lucide-react" import { salesApi } from "@/lib/api/sales" -import { salesConfigApi } from "@/lib/api/sales-config" import { customersApi } from "@/lib/api/customers" import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" import { useAuth } from "@/components/auth/AuthProvider" -import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { Button, buttonVariants } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" +import { DateRangePicker } from "@/components/ui/date-picker" import { FilterBar } from "@/components/ui/filter-bar" import { PaginationFooter } from "@/components/ui/pagination-footer" import { Field, FieldLabel } from "@/components/ui/field" @@ -36,18 +33,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" -import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog" -import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint" import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges" import { PaginationMeta } from "@/types/common" -import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales" +import { SalesInvoiceSummary } from "@/types/sales" import { Customer } from "@/types/customers" import { Warehouse } from "@/types/master-data" import { cn } from "@/lib/utils" -type StatusFilter = SalesInvoiceStatus | "All" -type SortKey = "invoiceNo" | "customer" | "date" | "due" | "net" | "paid" | "remaining" +type SortKey = "invoiceNo" | "customer" | "date" | "due" | "gross" | "discount" | "net" type SortOrder = "asc" | "desc" /** Same clickable sort header as the Items table. */ @@ -68,13 +61,15 @@ function formatAmount(n: number) { return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } -type DateRange = "All" | "Today" | "7d" | "30d" +type DateRange = "All" | "Today" | "7d" | "30d" | "Custom" +type CustomRange = { from: Date | undefined; to?: Date | undefined } const dateRanges: { value: DateRange; label: string; days: number | null }[] = [ { value: "All", label: "All time", days: null }, { value: "Today", label: "Today", days: 1 }, { value: "7d", label: "7 days", days: 7 }, { value: "30d", label: "30 days", days: 30 }, + { value: "Custom", label: "Custom range", days: null }, ] /** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */ @@ -82,8 +77,13 @@ function toIsoDate(d: Date) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}` } -/** Inclusive range ending today: "7 days" is today plus the 6 days before it. */ -function dateRangeBounds(range: DateRange): { from?: string; to?: string } { +/** Inclusive range ending today: "7 days" is today plus the 6 days before it. A custom range with + * only its start picked so far covers just that day; with nothing picked it doesn't filter. */ +function dateRangeBounds(range: DateRange, custom: CustomRange | undefined): { from?: string; to?: string } { + if (range === "Custom") { + if (!custom?.from) return {} + return { from: toIsoDate(custom.from), to: toIsoDate(custom.to ?? custom.from) } + } const days = dateRanges.find((r) => r.value === range)?.days if (!days) return {} const today = new Date() @@ -95,12 +95,6 @@ function dateRangeBounds(range: DateRange): { from?: string; to?: string } { const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] // Mobile has no page-size selector — tapping "Show more" just grows the page size by the // chosen rows-per-page and re-fetches from page 1, so loaded cards stay put and more append. -const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [ - { value: "All", label: "All" }, - { value: "Draft", label: "Draft" }, - { value: "Posted", label: "Approved" }, - { value: "Cancelled", label: "Cancelled" }, -] /** Past its due date (the "Due" column, `invoiceDate`) with money still owed — Draft and Cancelled * invoices are never overdue. Compared by calendar day, so an invoice due today is not overdue. */ @@ -113,20 +107,16 @@ function isOverdue(row: SalesInvoiceSummary): boolean { return due < today } -/** Money still owed on a live invoice — shown in red. A cancelled invoice owes nothing. */ -function hasRemaining(row: SalesInvoiceSummary): boolean { - return row.status !== "Cancelled" && row.totals.balanceAmount > 0 -} - export default function SalesInvoicesPage() { const { activeBranchId, branchingEnabled } = useAuth() - const stats = useDashboardStats() const [rows, setRows] = useState(null) const [pagination, setPagination] = useState(null) const [error, setError] = useState(null) - const [status, setStatus] = useState("All") const [dateRange, setDateRange] = useState("All") + const [customRange, setCustomRange] = useState(undefined) + // Strings, so the effects below re-run only when the actual bounds change. + const { from: dateFrom, to: dateTo } = dateRangeBounds(dateRange, customRange) const [searchInput, setSearchInput] = useState("") const [query, setQuery] = useState("") const [page, setPage] = useState(1) @@ -135,25 +125,19 @@ export default function SalesInvoicesPage() { const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [showMoreLoading, setShowMoreLoading] = useState(false) const [confirmingId, setConfirmingId] = useState(null) - const [payDialogInvoice, setPayDialogInvoice] = useState(null) - const [payDialogOpen, setPayDialogOpen] = useState(false) - // The invoice open in the pop-up view (row click, invoice no. or the print icon). Read through - // `rows` so its buttons follow payments/confirms; viewRefresh reloads the sheet after those. - const [viewInvoice, setViewInvoice] = useState(null) - const [viewRefresh, setViewRefresh] = useState(0) - const viewRow = viewInvoice ? (rows?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null + // This screen lists drafts only; the row, its invoice no. and the invoice icon all open the + // invoice page, where a draft is editable. + const router = useRouter() - // Multi-invoice payment: ticked rows (kept across pages), all for one customer. The generic cash - // customer's invoices can't be ticked — they're paid one at a time. - const [selected, setSelected] = useState>(new Map()) - const [bulkPayOpen, setBulkPayOpen] = useState(false) - const [cashCustomerId, setCashCustomerId] = useState(null) + function openDraft(row: SalesInvoiceSummary) { + router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`) + } + + // Ticked drafts for "Approve selected" — cleared whenever the list's filters change. + const [selected, setSelected] = useState>(new Set()) + const [bulkApproving, setBulkApproving] = useState(false) const [customerId, setCustomerId] = useState(undefined) - // While invoices are ticked the list is narrowed to their customer (overriding the Customer - // filter); clearing the selection falls back to whatever filter was chosen. - const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null - const listCustomerId = selectedCustomerId ?? customerId const [warehouseId, setWarehouseId] = useState(undefined) const [customers, setCustomers] = useState([]) const [warehouses, setWarehouses] = useState([]) @@ -166,13 +150,12 @@ export default function SalesInvoicesPage() { useEffect(() => { setPage(1) setPageSize(rowsPerPage) - }, [query, status, dateRange, activeBranchId, listCustomerId, warehouseId, rowsPerPage]) + setSelected(new Set()) + }, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage]) useEffect(() => { customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([])) - // A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway. - salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null)) }, []) useEffect(() => { @@ -181,11 +164,13 @@ export default function SalesInvoicesPage() { .listInvoices({ page, pageSize, - status: status === "All" ? undefined : status, + // Drafts only — approved invoices are worked (and paid) from the Day End screen. + status: "Draft", branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined, - customerId: listCustomerId, + customerId, warehouseId, - ...dateRangeBounds(dateRange), + from: dateFrom, + to: dateTo, }) .then((res) => { setRows(res.items) @@ -193,7 +178,7 @@ export default function SalesInvoicesPage() { }) .catch((err) => setError(errorMessage(err))) .finally(() => setShowMoreLoading(false)) - }, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, listCustomerId, warehouseId]) + }, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId]) function handleShowMore() { setShowMoreLoading(true) @@ -204,9 +189,14 @@ export default function SalesInvoicesPage() { setConfirmingId(row.salesInvoiceId) try { const posted = await salesApi.postInvoice(row.salesInvoiceId) - setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev) + // No longer a draft, so it leaves this list — it's now on Day End. + setRows((prev) => prev?.filter((r) => r.salesInvoiceId !== row.salesInvoiceId) ?? prev) + setSelected((prev) => { + const next = new Set(prev) + next.delete(row.salesInvoiceId) + return next + }) toast.success("Invoice confirmed", posted.invoiceNo) - setViewRefresh((n) => n + 1) } catch (err) { toast.error("Could not confirm invoice", errorMessage(err)) } finally { @@ -214,81 +204,38 @@ export default function SalesInvoicesPage() { } } - function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) { - setViewRefresh((n) => n + 1) - setRows( - (prev) => - prev?.map((r) => - r.salesInvoiceId === payment.salesInvoiceId - ? { - ...r, - status: newStatus, - totals: { ...r.totals, paidAmount: r.totals.paidAmount + payment.amount, balanceAmount: r.totals.balanceAmount - payment.amount }, - } - : r - ) ?? prev - ) - // Keep a ticked invoice's balance current, and untick it once nothing is left to pay. + // No bulk endpoint — each draft is approved in turn (each posts its own stock + GL), so one that + // fails (e.g. short stock) stays ticked in the list while the rest go through. + async function handleBulkApprove() { + const ids = [...selected] + setBulkApproving(true) + const approved: number[] = [] + const failed: string[] = [] + for (const id of ids) { + const row = rows?.find((r) => r.salesInvoiceId === id) + try { + await salesApi.postInvoice(id) + approved.push(id) + } catch (err) { + failed.push(`${row?.invoiceNo ?? id}: ${errorMessage(err)}`) + } + } + setRows((prev) => prev?.filter((r) => !approved.includes(r.salesInvoiceId)) ?? prev) + setSelected((prev) => new Set([...prev].filter((id) => !approved.includes(id)))) + setBulkApproving(false) + if (approved.length > 0) toast.success("Invoices approved", `${approved.length} invoice(s) approved.`) + if (failed.length > 0) toast.error(`${failed.length} invoice(s) could not be approved`, failed.join("\n")) + } + + function toggleSelected(id: number) { setSelected((prev) => { - const sel = prev.get(payment.salesInvoiceId) - if (!sel) return prev - const next = new Map(prev) - const balanceAmount = sel.totals.balanceAmount - payment.amount - if (balanceAmount <= 0) next.delete(payment.salesInvoiceId) - else next.set(payment.salesInvoiceId, { ...sel, totals: { ...sel.totals, paidAmount: sel.totals.paidAmount + payment.amount, balanceAmount } }) + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) return next }) } - function handleBulkConfirmed(salesInvoiceId: number) { - const asPosted = (r: SalesInvoiceSummary) => (r.salesInvoiceId === salesInvoiceId ? { ...r, status: "Posted" as const } : r) - setRows((prev) => prev?.map(asPosted) ?? prev) - setSelected((prev) => { - const sel = prev.get(salesInvoiceId) - return sel ? new Map(prev).set(salesInvoiceId, asPosted(sel)) : prev - }) - } - - function handleBulkPaid(payments: SalesInvoicePayment[]) { - payments.forEach((p) => handlePaid(p, "Posted")) - // Partly-paid ones would stay ticked via handlePaid; a finished payment clears the whole selection. - setSelected(new Map()) - } - - const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0) - - /** Null when the row can be ticked, else why not (shown as the checkbox's tooltip). */ - function notSelectableReason(row: SalesInvoiceSummary): string | null { - if (selected.has(row.salesInvoiceId)) return null - if (row.status === "Cancelled") return "Cancelled invoices can't be paid" - if (row.totals.balanceAmount <= 0) return "Already fully paid" - if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time" - if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only" - return null - } - - function toggleSelected(row: SalesInvoiceSummary) { - setSelected((prev) => { - const next = new Map(prev) - if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId) - else next.set(row.salesInvoiceId, row) - return next - }) - } - - function renderSelectBox(row: SalesInvoiceSummary) { - const reason = notSelectableReason(row) - return ( - toggleSelected(row)} - /> - ) - } - // Desktop table sorting (Items-table style) — over the loaded page, client-side; null keeps the // server's order. const [sortKey, setSortKey] = useState(null) @@ -316,9 +263,9 @@ export default function SalesInvoicesPage() { case "customer": return r.customerSnapshotName case "date": return new Date(r.createdAt).getTime() case "due": return new Date(r.invoiceDate).getTime() + case "gross": return r.totals.subtotal + case "discount": return r.totals.discountTotal case "net": return r.totals.grandTotal - case "paid": return r.totals.paidAmount - case "remaining": return r.status === "Cancelled" ? 0 : r.totals.balanceAmount } } return [...visibleRows].sort((a, b) => { @@ -328,20 +275,28 @@ export default function SalesInvoicesPage() { }) }, [visibleRows, sortKey, sortOrder]) - // Header "select all": ticks every tickable row on screen, but only when they're all one customer - // (e.g. once a selection has narrowed the list) — invoices of different customers never mix. - const tickable = sortedRows.filter((r) => notSelectableReason(r) === null) - const canSelectAll = tickable.length > 0 && new Set(tickable.map((r) => r.customerId)).size === 1 - const allTicked = tickable.length > 0 && tickable.every((r) => selected.has(r.salesInvoiceId)) + // Header "select all" ticks every draft on screen (the loaded page, after search). + const allTicked = sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.salesInvoiceId)) function toggleAll(checked: boolean) { setSelected((prev) => { - const next = new Map(prev) - tickable.forEach((r) => (checked ? next.set(r.salesInvoiceId, r) : next.delete(r.salesInvoiceId))) + const next = new Set(prev) + sortedRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId))) return next }) } - const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined + function renderSelectBox(row: SalesInvoiceSummary) { + return ( + toggleSelected(row.salesInvoiceId)} + /> + ) + } + + const hasFilters = dateFrom !== undefined || query.length > 0 || customerId !== undefined || warehouseId !== undefined const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0) const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) @@ -361,28 +316,26 @@ export default function SalesInvoicesPage() {
- {/* Totals are over the invoices currently listed (filters + loaded page). Passed as - strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */} + {/* Same order as the table: how many drafts, then Gross → Discount → Net. Draft count is every + matching draft; the amounts are over the invoices currently listed (filters + loaded page), + passed as strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
- {stats ? ( - - ) : ( - - )} {rows !== null ? ( <> + ) : ( - Array.from({ length: 3 }).map((_, i) => ) + Array.from({ length: 4 }).map((_, i) => ) )}
Customer - value={dateRange} onValueChange={(v) => setDateRange(v ?? "All")} @@ -449,26 +385,25 @@ export default function SalesInvoicesPage() { ))} + + {dateRange === "Custom" && ( + + )}
{selected.size > 0 && (

- {selected.size} invoice{selected.size === 1 ? "" : "s"} selected for{" "} - {selected.values().next().value!.customerSnapshotName} — balance{" "} - {formatAmount(selectedBalance)} - - Showing only this customer's invoices — clear the selection to see all. - + {selected.size} draft{selected.size === 1 ? "" : "s"} selected

- -
@@ -495,6 +430,15 @@ export default function SalesInvoicesPage() { <> {/* Mobile: stacked cards. */}
+ {visibleRows.map((row) => (
{ if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - setViewInvoice(row) + openDraft(row) }} onKeyDown={(e) => { - if (e.key === "Enter") setViewInvoice(row) + if (e.key === "Enter") openDraft(row) }} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" > @@ -515,8 +459,8 @@ export default function SalesInvoicesPage() { {renderSelectBox(row)}
-

{row.invoiceNo}

-

{row.customerSnapshotName}

+

{row.invoiceNo}

+

{row.customerSnapshotName}

@@ -538,22 +482,17 @@ export default function SalesInvoicesPage() {

-

Gross / Discount

-

{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}

+

Gross

+

{row.totals.subtotal.toFixed(2)}

+
+
+

Discount

+

{row.totals.discountTotal.toFixed(2)}

Net

{row.totals.grandTotal.toFixed(2)}

-
-

Paid / Remaining

-

- {row.totals.paidAmount.toFixed(2)} /{" "} - - {row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)} - -

-
@@ -561,11 +500,11 @@ export default function SalesInvoicesPage() { type="button" variant="outline" size="icon" - onClick={() => setViewInvoice(row)} - aria-label={`Print ${row.invoiceNo}`} - title="Print" + onClick={() => openDraft(row)} + aria-label={`Open ${row.invoiceNo} to edit`} + title="Open draft to edit" > - + {row.status === "Draft" && ( )} - {row.status !== "Cancelled" && row.totals.balanceAmount > 0 && ( - - )}
))} @@ -613,20 +537,20 @@ export default function SalesInvoicesPage() { toggleAll(c === true)} aria-label="Select all" - title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"} + title="Select all" /> toggleSort("invoiceNo")} /> toggleSort("customer")} /> toggleSort("date")} /> toggleSort("due")} /> + toggleSort("gross")} alignRight /> + toggleSort("discount")} alignRight /> toggleSort("net")} alignRight /> - toggleSort("paid")} alignRight /> - toggleSort("remaining")} alignRight /> Status Actions @@ -638,7 +562,7 @@ export default function SalesInvoicesPage() { className="cursor-pointer" onClick={(e) => { if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - setViewInvoice(row) + openDraft(row) }} > @@ -654,12 +578,6 @@ export default function SalesInvoicesPage() { href={`/dashboard/sales/invoices/${row.salesInvoiceId}`} className="hover:underline" data-no-row-nav - onClick={(e) => { - // Plain click opens the pop-up; Ctrl/⌘-click still opens the full page in a new tab. - if (e.ctrlKey || e.metaKey || e.shiftKey) return - e.preventDefault() - setViewInvoice(row) - }} > {row.invoiceNo} @@ -672,17 +590,9 @@ export default function SalesInvoicesPage() { {new Date(row.invoiceDate).toLocaleDateString()} - -

{row.totals.grandTotal.toFixed(2)}

-

- Gross {row.totals.subtotal.toFixed(2)} - {row.totals.discountTotal > 0 && ` · Disc ${row.totals.discountTotal.toFixed(2)}`} -

-
- {row.totals.paidAmount.toFixed(2)} - - {row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)} - + {row.totals.subtotal.toFixed(2)} + {row.totals.discountTotal.toFixed(2)} + {row.totals.grandTotal.toFixed(2)}
@@ -697,11 +607,11 @@ export default function SalesInvoicesPage() { type="button" variant="ghost" size="icon-sm" - onClick={() => setViewInvoice(row)} - aria-label={`Print ${row.invoiceNo}`} - title="Print" + onClick={() => openDraft(row)} + aria-label={`Open ${row.invoiceNo} to edit`} + title="Open draft to edit" > - + {row.status === "Draft" && ( )} - {row.status !== "Cancelled" && row.totals.balanceAmount > 0 && ( - - )}
@@ -752,56 +647,6 @@ export default function SalesInvoicesPage() { )} - - - - { if (!open) setViewInvoice(null) }} - refreshKey={viewRefresh} - actions={ - viewRow && ( - <> - {viewRow.status === "Draft" && ( - - )} - {viewRow.status !== "Cancelled" && viewRow.totals.balanceAmount > 0 && ( - - )} - - ) - } - /> ) } diff --git a/Frontend/erp-system/lib/api/customers.ts b/Frontend/erp-system/lib/api/customers.ts index c588721..2cd8218 100644 --- a/Frontend/erp-system/lib/api/customers.ts +++ b/Frontend/erp-system/lib/api/customers.ts @@ -13,6 +13,9 @@ export interface ListCustomersParams { /** The system cash customer (walk-in / one-time buyers) is left out by default — pass true only * where the list is used to resolve names for existing documents. */ includeCashCustomer?: boolean + /** Branch-wise list (same as Sales / GRN): only customers created under this branch, plus the + * shared cash customer. Omit for every branch. */ + branchId?: number } export const customersApi = { diff --git a/Frontend/erp-system/lib/api/sales.ts b/Frontend/erp-system/lib/api/sales.ts index 18be063..70b35e8 100644 --- a/Frontend/erp-system/lib/api/sales.ts +++ b/Frontend/erp-system/lib/api/sales.ts @@ -35,6 +35,12 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams { /** yyyy-MM-dd, inclusive; filters on InvoiceDate. */ from?: string to?: string + /** UserId who created the invoice — Day End's cashier match. */ + createdBy?: number + /** true = fully paid, false = balance still due. */ + paid?: boolean + /** Matches invoice number or customer name. */ + q?: string } export interface ListSalesSlipsParams extends SalesListFilterParams { diff --git a/Frontend/erp-system/types/sales.ts b/Frontend/erp-system/types/sales.ts index 39ecb85..d08046e 100644 --- a/Frontend/erp-system/types/sales.ts +++ b/Frontend/erp-system/types/sales.ts @@ -51,6 +51,8 @@ export interface SalesInvoiceSummary { totals: SalesInvoiceTotals createdAt: string glJournalNo: string | null + /** UserId who created it — Day End's cashier. */ + createdBy: number } export interface SalesInvoicePostingIssue { -- 2.52.0