Compare commits

..

4 Commits

Author SHA1 Message Date
ashan_rusiru 759c512281 Merge branch 'production' into sales-&-GRN-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 10m50s
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 14m14s
2026-10-04 11:42:19 +00:00
ashan_rusiru f4591a193a Merge pull request 'product and sales fixers' (#93) from product-and-sales-fixers into production
Reviewed-on: #93
2026-10-04 06:57:46 +00:00
ashan_rusiru 791d853c72 Merge branch 'production' into product-and-sales-fixers
CI/CD - ERP Core Monorepo / Build & Deploy Frontend (pull_request) Failing after 13m51s
CI/CD - ERP Core Monorepo / Build & Deploy Backend (pull_request) Failing after 15m33s
2026-10-04 06:57:30 +00:00
ImanThiyanga cba2f91689 Merge pull request 'Dev' (#91) from Dev into production
Reviewed-on: #91
2026-09-25 09:04:19 +00:00
15 changed files with 501 additions and 1000 deletions
@@ -24,9 +24,8 @@ public sealed class CustomersController : ApiControllerBase
[FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType,
CancellationToken ct,
[FromQuery] bool includeCashCustomer = false,
[FromQuery] int? branchId = null)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
[FromQuery] bool includeCashCustomer = false)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct));
[HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
@@ -90,10 +90,8 @@ 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, createdBy, paid, ct));
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
[HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -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, int CreatedBy);
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
public sealed record SalesInvoicePaymentDto(
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
+7 -11
View File
@@ -34,23 +34,19 @@ public sealed class CustomerService : ICustomerService
}
public async Task<PagedResponse<CustomerDto>> ListAsync(
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default)
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, 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();
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface ICustomerService
{
/// <summary>Registered customers; the system cash customer is left out unless <paramref name="includeCashCustomer"/>.</summary>
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default);
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default);
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService
{
Task<PagedResponse<SalesInvoiceSummaryDto>> 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<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow;
}
public async Task<PagedResponse<SalesInvoiceSummaryDto>> 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)
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
{
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q))
@@ -67,11 +67,6 @@ 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);
@@ -229,5 +224,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.CreatedBy);
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
}
@@ -81,11 +81,7 @@ export default function CustomersPage() {
return () => clearTimeout(timeout)
}, [searchInput])
// 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])
useEffect(() => setPage(1), [query, status, customerType])
function load() {
setError(null)
@@ -94,7 +90,6 @@ export default function CustomersPage() {
q: query || undefined,
status: status === "All" ? undefined : status,
customerType: customerType === "All" ? undefined : customerType,
branchId: listBranchId,
page,
pageSize,
})
@@ -111,7 +106,7 @@ export default function CustomersPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
}
useEffect(load, [query, status, customerType, listBranchId, page, pageSize])
useEffect(load, [query, status, customerType, page, pageSize])
useEffect(() => {
// Every code in use, the hidden cash customer's included, so a generated code never collides.
@@ -2,27 +2,18 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react"
import { AlertTriangle, ArrowLeft, CheckCircle2, 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, SalesInvoiceSummary } from "@/types/sales"
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview } 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"
@@ -32,51 +23,6 @@ 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<ManagedUser[]>([])
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
@@ -89,49 +35,6 @@ export default function SalesDayEndPage() {
const [report, setReport] = useState<SalesDayEnd | null>(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<number | "All" | null>(null)
const effectiveSalesCashier = salesCashier ?? cashierUserId
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today")
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("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<SalesInvoiceSummary | null>(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<SalesInvoiceSummary | null>(null)
const [payOpen, setPayOpen] = useState(false)
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
const [bulkPayOpen, setBulkPayOpen] = useState(false)
const [cashCustomerId, setCashCustomerId] = useState<number | null>(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 })
@@ -159,71 +62,6 @@ 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)
@@ -241,26 +79,6 @@ 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 (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
@@ -278,7 +96,7 @@ export default function SalesDayEndPage() {
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
<div className="flex flex-col gap-2">
<Label className="text-base">Cashier</Label>
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
<Select<number | null> value={cashierUserId} onValueChange={setCashierUserId}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select cashier" />
</SelectTrigger>
@@ -476,373 +294,6 @@ export default function SalesDayEndPage() {
<InvoiceSummaryPanel invoices={report.invoices} />
</div>
)}
<SalesTable
rows={sales}
error={salesError}
cashiers={cashiers}
cashierFilter={effectiveSalesCashier}
onCashierFilterChange={changeSalesCashier}
dateRange={salesDateRange}
onDateRangeChange={changeSalesDateRange}
paymentFilter={salesPayment}
onPaymentFilterChange={changeSalesPayment}
search={salesSearchInput}
onSearchChange={setSalesSearchInput}
pagination={salesPagination}
onPageChange={setSalesPage}
onPageSizeChange={changeSalesPageSize}
onView={setViewInvoice}
onPay={(row) => {
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. */}
<SalesInvoicePrintDialog
invoiceId={viewRow?.salesInvoiceId ?? null}
invoiceNo={viewRow?.invoiceNo}
open={viewRow !== null}
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
refreshKey={salesReload}
actions={
viewRow && viewRow.totals.balanceAmount > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setPayInvoice(viewRow)
setPayOpen(true)
}}
aria-label={`Pay ${viewRow.invoiceNo}`}
>
<CircleDollarSign className="size-4" />
Pay
</Button>
) : null
}
/>
{/* After a payment the table is re-read, so Paid / Remaining come from the server. */}
<SalesInvoicePaymentDialog invoice={payInvoice} open={payOpen} onOpenChange={setPayOpen} onPaid={reloadSales} />
<SalesInvoiceBulkPaymentDialog
invoices={[...selected.values()]}
open={bulkPayOpen}
onOpenChange={setBulkPayOpen}
onConfirmed={() => {}}
onPaid={reloadSales}
/>
</div>
)
}
// 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<number, SalesInvoiceSummary>
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 (
<Checkbox
checked={selected.has(row.salesInvoiceId)}
disabled={reason !== null}
title={reason ?? "Select to pay together"}
aria-label={`Select ${row.invoiceNo}`}
onCheckedChange={() => onToggle(row)}
/>
)
}
// View opens the invoice pop-up; Pay shows while a balance is still due.
function renderActions(row: SalesInvoiceSummary) {
return (
<div className="flex items-center justify-end gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() => onView(row)}
aria-label={`View ${row.invoiceNo}`}
title="View invoice"
>
<FileText className="size-4" />
</Button>
{row.totals.balanceAmount > 0 && (
<Button type="button" variant="outline" size="sm" onClick={() => onPay(row)} aria-label={`Pay ${row.invoiceNo}`}>
<CircleDollarSign className="size-4" />
Pay
</Button>
)}
</div>
)
}
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 (
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
<div>
<h2 className="text-lg font-semibold text-foreground">Sales</h2>
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Select<number | "All" | null>
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 }))]}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Cashier">
<SelectValue placeholder="Cashier" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All cashiers</SelectItem>
{cashiers.map((c) => (
<SelectItem key={c.userId} value={c.userId}>{c.displayName}</SelectItem>
))}
</SelectContent>
</Select>
<Select<SalesDateRange>
value={dateRange}
onValueChange={(v) => onDateRangeChange(v ?? "Today")}
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Date range">
<CalendarDays className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{salesDateRanges.map((r) => (
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<PaymentFilter>
value={paymentFilter}
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
>
<SelectTrigger className="h-10! w-full text-sm" aria-label="Payment status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{paymentFilters.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={search}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Search invoice or customer"
className="h-10 text-sm"
/>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
{!error && rows === null && <Skeleton className="h-32 w-full" />}
{!error && rows !== null && visible.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{filtered ? "No sales match these filters." : "No approved sales in this period."}
</p>
)}
{selected.size > 0 && (
<div className="flex flex-col gap-2 rounded-lg border bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm">
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}>
Clear
</Button>
<Button type="button" size="sm" onClick={onPaySelected}>
<CircleDollarSign className="size-4" />
Pay selected
</Button>
</div>
</div>
)}
{!error && rows !== null && visible.length > 0 && (
<>
{/* Mobile: stacked rows. */}
<div className="flex flex-col gap-1.5 md:hidden">
{visible.map((row) => (
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
{renderSelectBox(row)}
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
<span className="min-w-0">
<span className="block truncate font-medium">{row.invoiceNo}</span>
<span className="block truncate text-muted-foreground">
{row.customerSnapshotName}
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
</span>
<span className="block text-xs text-muted-foreground">
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</span>
</span>
</span>
<span className="flex shrink-0 flex-col items-end gap-1">
<span className="font-mono tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
</span>
</button>
{renderActions(row)}
</div>
))}
</div>
{/* Desktop: table. */}
<div className="hidden overflow-x-auto rounded-xl border md:block">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-10 px-3" />
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
<TableHead className="h-12 px-3 text-sm">Due</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Paid</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((row) => (
<TableRow
key={row.salesInvoiceId}
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
onView(row)
}}
>
<TableCell className="px-3 py-3.5" data-no-row-nav>
{renderSelectBox(row)}
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
{showCashier && (
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
)}
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
<TableCell
className={cn(
"px-3 py-3.5 text-right tabular-nums",
row.status !== "Cancelled" && row.totals.balanceAmount > 0 && "font-semibold text-destructive"
)}
>
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex flex-col items-start gap-1">
<SalesInvoiceStatusBadge status={row.status} />
{row.status !== "Draft" && (
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
)}
</div>
</TableCell>
<TableCell className="px-3 py-3.5" data-no-row-nav>
{renderActions(row)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{pagination && (
<PaginationFooter
pagination={pagination}
onPageChange={onPageChange}
pageSizeOptions={SALES_PAGE_SIZE_OPTIONS}
onPageSizeChange={onPageSizeChange}
/>
)}
{/* Invoices counts every match; the amounts are for the rows on this page. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat label="Invoices" value={pagination?.totalItems ?? visible.length} isCount />
<Stat label="Net (this page)" value={netTotal} emphasize />
<Stat label="Paid (this page)" value={paidTotal} />
<Stat label="Remaining (this page)" value={balanceTotal} />
</div>
</>
)}
</div>
)
}
@@ -58,22 +58,6 @@ 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()
@@ -93,8 +77,6 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
const [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [lines, setLines] = useState<Line[]>([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<string | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
@@ -113,8 +95,6 @@ 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<number | null>(null)
// The shared cash customer — offered on every branch's Bill To picker.
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
useEffect(() => {
if (!Number.isFinite(invoiceId)) {
@@ -138,7 +118,6 @@ 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)
@@ -150,24 +129,24 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setCustomerId(doc.data.customerId)
setWarehouseId(doc.data.warehouseId)
setInvoiceType(doc.data.invoiceType)
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))
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,
}))
)
})
.catch((err) => setError(errorMessage(err)))
}, [invoiceId, resolvedParams.id])
@@ -205,21 +184,6 @@ 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),
@@ -277,12 +241,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
/** Saves the draft's edits; true when they were stored. `quiet` skips the toast (Approve's first step). */
async function save(quiet = false): Promise<boolean> {
if (!customerId || !warehouseId || !etag || !invoice) return false
async function save() {
if (!customerId || !warehouseId || !etag || !invoice) return
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.")
return false
return
}
setSaving(true)
@@ -315,37 +278,25 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
)
setInvoice(updated.data)
setEtag(updated.etag)
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
toast.success("Invoice saved", updated.data.invoiceNo)
} 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 approving this invoice.")
setError("Resolve stock shortages before posting 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 approved", posted.invoiceNo)
toast.success("Invoice posted", posted.invoiceNo)
router.refresh()
} catch (err) {
setError(errorMessage(err))
@@ -436,64 +387,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
)}
{/* 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 ? (
<div className="grid gap-4 py-5 md:grid-cols-3 print:hidden">
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</Label>
<select
value={customerId ? String(customerId) : ""}
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select customer</option>
{branchCustomers.map((c) => (
<option key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</Label>
{allowWarehouseSelection ? (
<select
value={warehouseId ? String(warehouseId) : ""}
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select warehouse</option>
{warehouses.map((w) => (
<option key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</option>
))}
</select>
) : (
// Pinned to the default sales warehouse — shown, not editable.
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invoice type</Label>
<select
value={invoiceType}
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
<option value="Cash">Cash</option>
<option value="Credit">Credit</option>
</select>
</div>
</div>
) : null}
<div className={cn("grid gap-6 py-5 md:grid-cols-2", isDraft && "hidden print:grid")}>
<div className="grid gap-6 py-5 md:grid-cols-2">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
@@ -507,91 +401,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
</div>
{isDraft ? (
<div className="print:hidden">
<SuggestInput
options={itemOptions}
onSelect={(v) => 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"
/>
<div className="overflow-x-auto border-y">
<table className="w-full text-sm">
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
<tr>
<th className="px-4 py-2 text-left font-medium">Item</th>
<th className="px-4 py-2 text-left font-medium">UOM</th>
<th className="px-4 py-2 text-right font-medium">Qty</th>
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
<th className="px-4 py-2 text-right font-medium">Unit price</th>
<th className="px-4 py-2 text-right font-medium">Remove</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.key} className="border-t border-border align-middle">
<td className="min-w-64 px-4 py-3">
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
</td>
<td className="min-w-32 px-4 py-3 text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</td>
<td className="w-28 px-4 py-3">
<input
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => 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"
/>
</td>
{allowFreeItems && (
<td className="w-28 px-4 py-3">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => 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"
/>
</td>
)}
<td className="w-32 px-4 py-3">
<input
type="number"
min="0"
step="0.01"
value={line.unitPrice ?? ""}
onChange={(e) => 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"
/>
</td>
<td className="w-24 px-4 py-3 text-right">
<button
type="button"
onClick={() => removeLine(line.key)}
disabled={lines.length === 1}
aria-label="Remove line"
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
>
<Minus className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-2 text-xs text-muted-foreground">Totals below update when the draft is saved.</p>
</div>
) : null}
<div className={cn("overflow-x-auto border-y", isDraft && "hidden print:block")}>
<div className="overflow-x-auto border-y">
<table className="w-full text-sm">
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
<tr>
@@ -719,17 +529,23 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
<div className="mt-6 border-t pt-5 print:hidden">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Actions</h3>
<span className="text-sm text-muted-foreground">
{isDraft ? "Edit the draft above, then save as draft or approve. Payments open once approved." : "Only draft invoices are editable."}
</span>
<span className="text-sm text-muted-foreground">{isDraft ? "Draft invoice can be edited." : "Only draft invoices are editable."}</span>
</div>
{isDraft ? (
<>
<SuggestInput
options={itemOptions}
onSelect={(v) => 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"
/>
<div className="mb-4 flex flex-wrap gap-2">
<button type="button" onClick={() => save()} disabled={!isDirty || saving || busy !== null} title={isDirty ? undefined : "No changes to save"} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
<button type="button" onClick={save} disabled={saving} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
<Save className="size-4" />
{saving && busy === null ? "Saving..." : "Save as Draft"}
{saving ? "Saving..." : "Save invoice"}
</button>
{postingCheck && !postingCheck.canPost ? (
<Link
@@ -742,13 +558,151 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
) : null}
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
<Send className="size-4" />
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Approving..." : "Approve invoice"}
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
</button>
<button type="button" onClick={cancel} disabled={busy !== null} className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-300 bg-rose-50 px-4 text-sm font-medium text-rose-700 shadow-sm hover:bg-rose-100 disabled:opacity-50">
<X className="size-4" />
{busy === "cancel" ? "Cancelling..." : "Cancel invoice"}
</button>
</div>
<div className="overflow-x-auto border">
<table className="w-full text-sm">
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
<tr>
<th className="px-4 py-2 text-left font-medium">Item</th>
<th className="px-4 py-2 text-left font-medium">UOM</th>
<th className="px-4 py-2 text-right font-medium">Qty</th>
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
<th className="px-4 py-2 text-right font-medium">Unit price</th>
<th className="px-4 py-2 text-right font-medium">Remove</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.key} className="border-t border-border align-middle">
<td className="px-4 py-3 min-w-64">
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
</td>
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</td>
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.qty}
onChange={(e) => 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"
/>
</td>
{allowFreeItems && (
<td className="px-4 py-3 w-28">
<input
type="number"
min="0"
step="1"
value={line.freeQty}
onChange={(e) => 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"
/>
</td>
)}
<td className="px-4 py-3 w-32">
<input
type="number"
min="0"
step="0.01"
value={line.unitPrice ?? ""}
onChange={(e) => 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"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</td>
<td className="px-4 py-3 w-24 text-right">
<button
type="button"
onClick={() => removeLine(line.key)}
disabled={lines.length === 1}
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
>
<Minus className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-5 grid gap-4 md:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Customer</Label>
<select
value={customerId ? String(customerId) : ""}
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select customer</option>
{customers.map((c) => (
<option key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
{allowWarehouseSelection ? (
<select
value={warehouseId ? String(warehouseId) : ""}
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
<option value="">Select warehouse</option>
{warehouses.map((w) => (
<option key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</option>
))}
</select>
) : (
// Pinned to the default sales warehouse — shown, not editable.
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
</div>
)}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Invoice type</Label>
<select
value={invoiceType}
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
>
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
<option value="Cash">Cash</option>
<option value="Credit">Credit</option>
</select>
</div>
</div>
<div className="mt-5 flex justify-end">
<button
type="button"
onClick={post}
disabled={!canPost}
className="inline-flex h-10 items-center gap-2 rounded-full bg-black px-5 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40"
>
<Send className="size-4" />
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
</button>
</div>
</>
) : (
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
@@ -137,8 +137,7 @@ function NewSalesInvoiceContent() {
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
// Which action is in flight — Save as Draft or Approve invoice.
const [saving, setSaving] = useState<"draft" | "approve" | null>(null)
const [saving, setSaving] = useState(false)
const [scanError, setScanError] = useState<string | null>(null)
// Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price.
const [resolvedPrices, setResolvedPrices] = useState<Record<string, number>>({})
@@ -160,16 +159,9 @@ 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 && (effectiveBranchId === null || c.branchId === effectiveBranchId),
),
[customers, salesConfig, effectiveBranchId],
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
[customers, salesConfig],
)
// 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.
@@ -255,6 +247,7 @@ 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]
@@ -269,12 +262,6 @@ 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
}
@@ -680,9 +667,7 @@ function NewSalesInvoiceContent() {
const netTotal = Math.max(0, grossTotal - discountTotal)
const payableTotal = netTotal + taxTotal
/** "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") {
async function submit() {
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.")
@@ -716,25 +701,12 @@ function NewSalesInvoiceContent() {
})),
}
setSaving(mode)
setSaving(true)
setSubmitError(null)
try {
const created = await salesApi.createInvoice(payload)
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")
}
toast.success("Invoice created", created.data.invoiceNo)
router.push(`/dashboard/sales/invoices/${created.data.salesInvoiceId}`)
} catch (err) {
if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) {
router.push(`/login?next=/dashboard/sales/invoices/new`)
@@ -742,7 +714,7 @@ function NewSalesInvoiceContent() {
}
setSubmitError(errorMessage(err))
} finally {
setSaving(null)
setSaving(false)
}
}
@@ -1306,11 +1278,8 @@ function NewSalesInvoiceContent() {
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => submit("draft")} disabled={saving !== null}>
{saving === "draft" ? "Saving…" : "Save as Draft"}
</Button>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => submit("approve")} disabled={saving !== null}>
{saving === "approve" ? "Approving…" : "Approve invoice"}
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"}
</Button>
</div>
</div>
@@ -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,20 +11,23 @@ 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"
@@ -33,14 +36,18 @@ 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 { SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data"
import { cn } from "@/lib/utils"
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "gross" | "discount" | "net"
type StatusFilter = SalesInvoiceStatus | "All"
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "net" | "paid" | "remaining"
type SortOrder = "asc" | "desc"
/** Same clickable sort header as the Items table. */
@@ -61,15 +68,13 @@ function formatAmount(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
type DateRange = "All" | "Today" | "7d" | "30d" | "Custom"
type CustomRange = { from: Date | undefined; to?: Date | undefined }
type DateRange = "All" | "Today" | "7d" | "30d"
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. */
@@ -77,13 +82,8 @@ 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. 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) }
}
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. */
function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
const days = dateRanges.find((r) => r.value === range)?.days
if (!days) return {}
const today = new Date()
@@ -95,6 +95,12 @@ function dateRangeBounds(range: DateRange, custom: CustomRange | undefined): { f
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. */
@@ -107,16 +113,20 @@ 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<SalesInvoiceSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [dateRange, setDateRange] = useState<DateRange>("All")
const [customRange, setCustomRange] = useState<CustomRange | undefined>(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)
@@ -125,19 +135,25 @@ export default function SalesInvoicesPage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false)
const [confirmingId, setConfirmingId] = useState<number | null>(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()
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(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<SalesInvoiceSummary | null>(null)
const [viewRefresh, setViewRefresh] = useState(0)
const viewRow = viewInvoice ? (rows?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : 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<Set<number>>(new Set())
const [bulkApproving, setBulkApproving] = useState(false)
// 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<Map<number, SalesInvoiceSummary>>(new Map())
const [bulkPayOpen, setBulkPayOpen] = useState(false)
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
const [customerId, setCustomerId] = useState<number | undefined>(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<number | undefined>(undefined)
const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -150,12 +166,13 @@ export default function SalesInvoicesPage() {
useEffect(() => {
setPage(1)
setPageSize(rowsPerPage)
setSelected(new Set())
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
}, [query, status, dateRange, activeBranchId, listCustomerId, 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(() => {
@@ -164,13 +181,11 @@ export default function SalesInvoicesPage() {
.listInvoices({
page,
pageSize,
// Drafts only — approved invoices are worked (and paid) from the Day End screen.
status: "Draft",
status: status === "All" ? undefined : status,
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId,
customerId: listCustomerId,
warehouseId,
from: dateFrom,
to: dateTo,
...dateRangeBounds(dateRange),
})
.then((res) => {
setRows(res.items)
@@ -178,7 +193,7 @@ export default function SalesInvoicesPage() {
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId])
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, listCustomerId, warehouseId])
function handleShowMore() {
setShowMoreLoading(true)
@@ -189,14 +204,9 @@ export default function SalesInvoicesPage() {
setConfirmingId(row.salesInvoiceId)
try {
const posted = await salesApi.postInvoice(row.salesInvoiceId)
// 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
})
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
toast.success("Invoice confirmed", posted.invoiceNo)
setViewRefresh((n) => n + 1)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
} finally {
@@ -204,38 +214,81 @@ export default function SalesInvoicesPage() {
}
}
// 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) {
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.
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
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 } })
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 (
<Checkbox
checked={selected.has(row.salesInvoiceId)}
disabled={reason !== null}
title={reason ?? "Select to pay together"}
aria-label={`Select ${row.invoiceNo}`}
onCheckedChange={() => toggleSelected(row)}
/>
)
}
// Desktop table sorting (Items-table style) — over the loaded page, client-side; null keeps the
// server's order.
const [sortKey, setSortKey] = useState<SortKey | null>(null)
@@ -263,9 +316,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) => {
@@ -275,28 +328,20 @@ export default function SalesInvoicesPage() {
})
}, [visibleRows, sortKey, sortOrder])
// 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))
// 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))
function toggleAll(checked: boolean) {
setSelected((prev) => {
const next = new Set(prev)
sortedRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId)))
const next = new Map(prev)
tickable.forEach((r) => (checked ? next.set(r.salesInvoiceId, r) : next.delete(r.salesInvoiceId)))
return next
})
}
function renderSelectBox(row: SalesInvoiceSummary) {
return (
<Checkbox
checked={selected.has(row.salesInvoiceId)}
disabled={bulkApproving}
aria-label={`Select ${row.invoiceNo}`}
onCheckedChange={() => toggleSelected(row.salesInvoiceId)}
/>
)
}
const hasFilters = dateFrom !== undefined || query.length > 0 || customerId !== undefined || warehouseId !== undefined
const hasFilters = status !== "All" || dateRange !== "All" || 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)
@@ -316,26 +361,28 @@ export default function SalesInvoicesPage() {
</div>
</div>
{/* 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. */}
{/* 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. */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{stats ? (
<StatCard label="Overdue Invoices" value={stats.overdueSalesInvoices} icon={AlertTriangle} />
) : (
<Skeleton className="h-22.5 rounded-xl" />
)}
{rows !== null ? (
<>
<StatCard label="Draft invoices" value={pagination?.totalItems ?? rows.length} icon={FileText} />
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
<StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} />
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
)}
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<FilterBar
className="border-b px-4 py-4"
searchFirst
search={searchInput}
onSearchChange={setSearchInput}
searchPlaceholder="Filter by customer or invoice number"
@@ -346,8 +393,9 @@ export default function SalesInvoicesPage() {
<Field>
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
<Select
value={customerId ? String(customerId) : "All"}
value={listCustomerId ? String(listCustomerId) : "All"}
onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}
disabled={selectedCustomerId !== null}
>
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -370,6 +418,22 @@ export default function SalesInvoicesPage() {
}
>
<div className="flex flex-wrap items-center gap-2">
<Select<StatusFilter>
value={status}
onValueChange={(v) => setStatus(v ?? "All")}
items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))}
>
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
<ListFilter className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{tabs.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.value === "All" ? "All statuses" : t.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<DateRange>
value={dateRange}
onValueChange={(v) => setDateRange(v ?? "All")}
@@ -385,25 +449,26 @@ export default function SalesInvoicesPage() {
))}
</SelectContent>
</Select>
{dateRange === "Custom" && (
<DateRangePicker value={customRange} onChange={setCustomRange} placeholder="Pick from – to dates" className="sm:w-72" />
)}
</div>
</FilterBar>
{selected.size > 0 && (
<div className="flex flex-col gap-2 border-b bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm">
<span className="font-semibold">{selected.size}</span> draft{selected.size === 1 ? "" : "s"} selected
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
<span className="font-semibold tabular-nums">{formatAmount(selectedBalance)}</span>
<span className="block text-xs text-muted-foreground">
Showing only this customer&apos;s invoices — clear the selection to see all.
</span>
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" disabled={bulkApproving} onClick={() => setSelected(new Set())}>
<Button type="button" variant="outline" size="sm" onClick={() => setSelected(new Map())}>
Clear
</Button>
<Button type="button" size="sm" disabled={bulkApproving} onClick={handleBulkApprove}>
{bulkApproving ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
{bulkApproving ? "Approving…" : "Approve selected"}
<Button type="button" size="sm" onClick={() => setBulkPayOpen(true)}>
<CircleDollarSign className="size-4" />
Pay selected
</Button>
</div>
</div>
@@ -430,15 +495,6 @@ export default function SalesInvoicesPage() {
<>
{/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
<label className="flex items-center gap-3 px-1 text-sm text-muted-foreground">
<Checkbox
checked={allTicked}
disabled={bulkApproving}
onCheckedChange={(c) => toggleAll(c === true)}
aria-label="Select all"
/>
Select all
</label>
{visibleRows.map((row) => (
<div
key={row.salesInvoiceId}
@@ -446,10 +502,10 @@ export default function SalesInvoicesPage() {
tabIndex={0}
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openDraft(row)
setViewInvoice(row)
}}
onKeyDown={(e) => {
if (e.key === "Enter") openDraft(row)
if (e.key === "Enter") setViewInvoice(row)
}}
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
>
@@ -459,8 +515,8 @@ export default function SalesInvoicesPage() {
{renderSelectBox(row)}
</span>
<div className="min-w-0">
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
</div>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
@@ -482,17 +538,22 @@ export default function SalesInvoicesPage() {
</p>
</div>
<div>
<p className="text-muted-foreground">Gross</p>
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Discount</p>
<p className="font-medium text-foreground">{row.totals.discountTotal.toFixed(2)}</p>
<p className="text-muted-foreground">Gross / Discount</p>
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Net</p>
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
</div>
<div>
<p className="text-muted-foreground">Paid / Remaining</p>
<p className="font-medium text-foreground">
{row.totals.paidAmount.toFixed(2)} /{" "}
<span className={cn(hasRemaining(row) && "font-semibold text-destructive")}>
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
</span>
</p>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
@@ -500,11 +561,11 @@ export default function SalesInvoicesPage() {
type="button"
variant="outline"
size="icon"
onClick={() => openDraft(row)}
aria-label={`Open ${row.invoiceNo} to edit`}
title="Open draft to edit"
onClick={() => setViewInvoice(row)}
aria-label={`Print ${row.invoiceNo}`}
title="Print"
>
<FileText className="size-4" />
<Printer className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
@@ -519,6 +580,21 @@ export default function SalesInvoicesPage() {
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
aria-label={`Pay ${row.invoiceNo}`}
title="Pay"
>
<CircleDollarSign className="size-4" />
</Button>
)}
</div>
</div>
))}
@@ -537,20 +613,20 @@ export default function SalesInvoicesPage() {
<TableRow>
<TableHead className="h-12 w-10 px-3">
<Checkbox
checked={allTicked}
disabled={bulkApproving}
checked={canSelectAll && allTicked}
disabled={!canSelectAll}
onCheckedChange={(c) => toggleAll(c === true)}
aria-label="Select all"
title="Select all"
title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"}
/>
</TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Invoice" active={sortKey === "invoiceNo"} order={sortOrder} onClick={() => toggleSort("invoiceNo")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Customer" active={sortKey === "customer"} order={sortOrder} onClick={() => toggleSort("customer")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Date" active={sortKey === "date"} order={sortOrder} onClick={() => toggleSort("date")} /></TableHead>
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Due" active={sortKey === "due"} order={sortOrder} onClick={() => toggleSort("due")} /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Gross" active={sortKey === "gross"} order={sortOrder} onClick={() => toggleSort("gross")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Discount" active={sortKey === "discount"} order={sortOrder} onClick={() => toggleSort("discount")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Net" active={sortKey === "net"} order={sortOrder} onClick={() => toggleSort("net")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Paid" active={sortKey === "paid"} order={sortOrder} onClick={() => toggleSort("paid")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Remaining" active={sortKey === "remaining"} order={sortOrder} onClick={() => toggleSort("remaining")} alignRight /></TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
@@ -562,7 +638,7 @@ export default function SalesInvoicesPage() {
className="cursor-pointer"
onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
openDraft(row)
setViewInvoice(row)
}}
>
<TableCell className="px-3 py-3.5" data-no-row-nav>
@@ -578,6 +654,12 @@ 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}
</Link>
@@ -590,9 +672,17 @@ export default function SalesInvoicesPage() {
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()}
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
<p className="font-semibold">{row.totals.grandTotal.toFixed(2)}</p>
<p className="text-xs text-muted-foreground">
Gross {row.totals.subtotal.toFixed(2)}
{row.totals.discountTotal > 0 && ` · Disc ${row.totals.discountTotal.toFixed(2)}`}
</p>
</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
<TableCell className={cn("px-3 py-3.5 text-right tabular-nums", hasRemaining(row) && "font-semibold text-destructive")}>
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
</TableCell>
<TableCell className="px-3 py-3.5">
<div className="flex flex-col items-start gap-1">
<SalesInvoiceStatusBadge status={row.status} />
@@ -607,11 +697,11 @@ export default function SalesInvoicesPage() {
type="button"
variant="ghost"
size="icon-sm"
onClick={() => openDraft(row)}
aria-label={`Open ${row.invoiceNo} to edit`}
title="Open draft to edit"
onClick={() => setViewInvoice(row)}
aria-label={`Print ${row.invoiceNo}`}
title="Print"
>
<FileText className="size-4" />
<Printer className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
@@ -626,6 +716,21 @@ export default function SalesInvoicesPage() {
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => {
setPayDialogInvoice(row)
setPayDialogOpen(true)
}}
aria-label={`Pay ${row.invoiceNo}`}
title="Pay"
>
<CircleDollarSign className="size-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
@@ -647,6 +752,56 @@ export default function SalesInvoicesPage() {
</>
)}
</div>
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
<SalesInvoiceBulkPaymentDialog
invoices={[...selected.values()]}
open={bulkPayOpen}
onOpenChange={setBulkPayOpen}
onConfirmed={handleBulkConfirmed}
onPaid={handleBulkPaid}
/>
<SalesInvoicePrintDialog
invoiceId={viewRow?.salesInvoiceId ?? null}
invoiceNo={viewRow?.invoiceNo}
open={viewRow !== null}
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
refreshKey={viewRefresh}
actions={
viewRow && (
<>
{viewRow.status === "Draft" && (
<Button
type="button"
variant="outline"
size="icon"
disabled={confirmingId === viewRow.salesInvoiceId}
onClick={() => handleConfirm(viewRow)}
aria-label={`Confirm ${viewRow.invoiceNo}`}
title="Confirm"
>
{confirmingId === viewRow.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button>
)}
{viewRow.status !== "Cancelled" && viewRow.totals.balanceAmount > 0 && (
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
setPayDialogInvoice(viewRow)
setPayDialogOpen(true)
}}
aria-label={`Pay ${viewRow.invoiceNo}`}
title="Pay"
>
<CircleDollarSign className="size-4" />
</Button>
)}
</>
)
}
/>
</div>
)
}
-3
View File
@@ -13,9 +13,6 @@ 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 = {
-6
View File
@@ -35,12 +35,6 @@ 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 {
-2
View File
@@ -51,8 +51,6 @@ export interface SalesInvoiceSummary {
totals: SalesInvoiceTotals
createdAt: string
glJournalNo: string | null
/** UserId who created it — Day End's cashier. */
createdBy: number
}
export interface SalesInvoicePostingIssue {