fixers of day end sales and sales

This commit is contained in:
2026-10-05 13:11:01 +05:30
parent 3d3dbbbb59
commit f4ec3b4c65
15 changed files with 1000 additions and 501 deletions
@@ -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.
@@ -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<ManagedUser[]>([])
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
@@ -35,6 +89,49 @@ 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 })
@@ -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 (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
@@ -96,7 +278,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={setCashierUserId}>
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
<SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select cashier" />
</SelectTrigger>
@@ -294,6 +476,373 @@ 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,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<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")
@@ -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<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)) {
@@ -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<boolean> {
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
</div>
)}
<div className="grid gap-6 py-5 md:grid-cols-2">
{/* 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>
<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>
@@ -401,7 +507,91 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div>
</div>
<div className="overflow-x-auto border-y">
{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")}>
<table className="w-full text-sm">
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
<tr>
@@ -529,23 +719,17 @@ 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 ? "Draft invoice can be edited." : "Only draft invoices are editable."}</span>
<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>
</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={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">
<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">
<Save className="size-4" />
{saving ? "Saving..." : "Save invoice"}
{saving && busy === null ? "Saving..." : "Save as Draft"}
</button>
{postingCheck && !postingCheck.canPost ? (
<Link
@@ -558,151 +742,13 @@ 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" ? "Posting..." : "Post invoice"}
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Approving..." : "Approve 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,7 +137,8 @@ function NewSalesInvoiceContent() {
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(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<string | null>(null)
// Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price.
const [resolvedPrices, setResolvedPrices] = useState<Record<string, number>>({})
@@ -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() {
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel
</Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
{saving ? "Creating…" : "Create Sale"}
<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>
</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,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<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)
@@ -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<number | null>(null)
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
// 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<Map<number, SalesInvoiceSummary>>(new Map())
const [bulkPayOpen, setBulkPayOpen] = useState(false)
const [cashCustomerId, setCashCustomerId] = useState<number | null>(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)
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[]>([])
@@ -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 (
<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)
@@ -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 (
<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 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() {
</div>
</div>
{/* 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. */}
<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: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
Array.from({ length: 4 }).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"
@@ -393,9 +346,8 @@ export default function SalesInvoicesPage() {
<Field>
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
<Select
value={listCustomerId ? String(listCustomerId) : "All"}
value={customerId ? String(customerId) : "All"}
onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}
disabled={selectedCustomerId !== null}
>
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
<SelectContent>
@@ -418,22 +370,6 @@ 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")}
@@ -449,26 +385,25 @@ 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> 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>
<span className="font-semibold">{selected.size}</span> draft{selected.size === 1 ? "" : "s"} selected
</p>
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setSelected(new Map())}>
<Button type="button" variant="outline" size="sm" disabled={bulkApproving} onClick={() => setSelected(new Set())}>
Clear
</Button>
<Button type="button" size="sm" onClick={() => setBulkPayOpen(true)}>
<CircleDollarSign className="size-4" />
Pay selected
<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>
</div>
</div>
@@ -495,6 +430,15 @@ 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}
@@ -502,10 +446,10 @@ export default function SalesInvoicesPage() {
tabIndex={0}
onClick={(e) => {
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)}
</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">
@@ -538,22 +482,17 @@ export default function SalesInvoicesPage() {
</p>
</div>
<div>
<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>
<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>
</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>
@@ -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"
>
<Printer className="size-4" />
<FileText className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
@@ -580,21 +519,6 @@ 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>
))}
@@ -613,20 +537,20 @@ export default function SalesInvoicesPage() {
<TableRow>
<TableHead className="h-12 w-10 px-3">
<Checkbox
checked={canSelectAll && allTicked}
disabled={!canSelectAll}
checked={allTicked}
disabled={bulkApproving}
onCheckedChange={(c) => toggleAll(c === true)}
aria-label="Select all"
title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"}
title="Select all"
/>
</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>
@@ -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)
}}
>
<TableCell className="px-3 py-3.5" data-no-row-nav>
@@ -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}
</Link>
@@ -672,17 +590,9 @@ 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">
<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 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">
<div className="flex flex-col items-start gap-1">
<SalesInvoiceStatusBadge status={row.status} />
@@ -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"
>
<Printer className="size-4" />
<FileText className="size-4" />
</Button>
{row.status === "Draft" && (
<Button
@@ -716,21 +626,6 @@ 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>
@@ -752,56 +647,6 @@ 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>
)
}