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
@@ -24,8 +24,9 @@ public sealed class CustomersController : ApiControllerBase
[FromQuery] EntityStatus? status, [FromQuery] EntityStatus? status,
[FromQuery] CustomerType? customerType, [FromQuery] CustomerType? customerType,
CancellationToken ct, CancellationToken ct,
[FromQuery] bool includeCashCustomer = false) [FromQuery] bool includeCashCustomer = false,
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct)); [FromQuery] int? branchId = null)
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
[HttpGet("{customerId:int}")] [HttpGet("{customerId:int}")]
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
@@ -90,8 +90,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
[FromQuery] int? branchId, [FromQuery] int? branchId,
[FromQuery] DateOnly? from, [FromQuery] DateOnly? from,
[FromQuery] DateOnly? to, [FromQuery] DateOnly? to,
[FromQuery] int? createdBy,
[FromQuery] bool? paid,
CancellationToken ct) CancellationToken ct)
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct)); => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
[HttpGet("{salesInvoiceId:int}")] [HttpGet("{salesInvoiceId:int}")]
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
@@ -23,7 +23,7 @@ public sealed record SalesInvoiceDto(
public sealed record SalesInvoiceSummaryDto( public sealed record SalesInvoiceSummaryDto(
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType, string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo); SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo, int CreatedBy);
public sealed record SalesInvoicePaymentDto( public sealed record SalesInvoicePaymentDto(
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate, int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
+10 -6
View File
@@ -34,19 +34,23 @@ public sealed class CustomerService : ICustomerService
} }
public async Task<PagedResponse<CustomerDto>> ListAsync( public async Task<PagedResponse<CustomerDto>> ListAsync(
PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, CancellationToken ct = default) PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default)
{ {
var q = _customers.Query().AsNoTracking(); var q = _customers.Query().AsNoTracking();
// 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() var cashCustomerId = await _salesConfig.Query().AsNoTracking()
.Where(c => c.ConfigId == SalesConfig.SingletonId) .Where(c => c.ConfigId == SalesConfig.SingletonId)
.Select(c => c.CashCustomerId) .Select(c => c.CashCustomerId)
.FirstOrDefaultAsync(ct); .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)
q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId)); 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)) if (!string.IsNullOrWhiteSpace(query.Q))
{ {
var term = query.Q.Trim(); var term = query.Q.Trim();
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
public interface ICustomerService public interface ICustomerService
{ {
/// <summary>Registered customers; the system cash customer is left out unless <paramref name="includeCashCustomer"/>.</summary> /// <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, CancellationToken ct = default); Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, int? branchId = null, CancellationToken ct = default);
Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default); Task<ETagged<CustomerDto>?> GetAsync(int customerId, CancellationToken ct = default);
Task<ETagged<CustomerDto>> CreateAsync(CreateCustomerRequest request, 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); Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
public interface ISalesInvoiceService public interface ISalesInvoiceService
{ {
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default); 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<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
_uow = uow; _uow = uow;
} }
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) 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)
{ {
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines); IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
if (!string.IsNullOrWhiteSpace(query.Q)) if (!string.IsNullOrWhiteSpace(query.Q))
@@ -67,6 +67,11 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService. // 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 (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)); 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 total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
@@ -224,5 +229,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new( private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName, x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo); x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo, x.CreatedBy);
} }
@@ -81,7 +81,11 @@ export default function CustomersPage() {
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
}, [searchInput]) }, [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() { function load() {
setError(null) setError(null)
@@ -90,6 +94,7 @@ export default function CustomersPage() {
q: query || undefined, q: query || undefined,
status: status === "All" ? undefined : status, status: status === "All" ? undefined : status,
customerType: customerType === "All" ? undefined : customerType, customerType: customerType === "All" ? undefined : customerType,
branchId: listBranchId,
page, page,
pageSize, pageSize,
}) })
@@ -106,7 +111,7 @@ export default function CustomersPage() {
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
} }
useEffect(load, [query, status, customerType, page, pageSize]) useEffect(load, [query, status, customerType, listBranchId, page, pageSize])
useEffect(() => { useEffect(() => {
// Every code in use, the hidden cash customer's included, so a generated code never collides. // 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 { useEffect, useState } from "react"
import Link from "next/link" 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 { salesDayEndApi } from "@/lib/api/sales-day-end"
import { usersApi } from "@/lib/api/users" import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { ManagedUser } from "@/types/users" 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 { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton" import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" 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) 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() { export default function SalesDayEndPage() {
const [cashiers, setCashiers] = useState<ManagedUser[]>([]) const [cashiers, setCashiers] = useState<ManagedUser[]>([])
const [cashierUserId, setCashierUserId] = useState<number | null>(null) const [cashierUserId, setCashierUserId] = useState<number | null>(null)
@@ -35,6 +89,49 @@ export default function SalesDayEndPage() {
const [report, setReport] = useState<SalesDayEnd | null>(null) const [report, setReport] = useState<SalesDayEnd | null>(null)
const [closing, setClosing] = useState(false) 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(() => { useEffect(() => {
usersApi usersApi
.list({ pageSize: 200 }) .list({ pageSize: 200 })
@@ -62,6 +159,71 @@ export default function SalesDayEndPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [cashierUserId, businessDate]) }, [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() { async function handleClose() {
if (!cashierUserId) return if (!cashierUserId) return
setClosing(true) setClosing(true)
@@ -79,6 +241,26 @@ export default function SalesDayEndPage() {
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName 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 ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center gap-3"> <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="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"> <div className="flex flex-col gap-2">
<Label className="text-base">Cashier</Label> <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"> <SelectTrigger className="h-12! w-full text-base">
<SelectValue placeholder="Select cashier" /> <SelectValue placeholder="Select cashier" />
</SelectTrigger> </SelectTrigger>
@@ -294,6 +476,373 @@ export default function SalesDayEndPage() {
<InvoiceSummaryPanel invoices={report.invoices} /> <InvoiceSummaryPanel invoices={report.invoices} />
</div> </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> </div>
) )
} }
@@ -58,6 +58,22 @@ const blankLine = (key: string): Line => ({
parentLineId: null, 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. */ /** "Posted" reads as "approved" everywhere in the UI — matches SalesInvoiceStatusBadge's label. */
function statusWord(status: SalesInvoiceStatus): string { function statusWord(status: SalesInvoiceStatus): string {
return status === "Posted" ? "approved" : status.toLowerCase() 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 [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null) const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")]) 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 [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null) const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C") 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. // editor's Warehouse picker with a locked field — the server keeps the invoice on it regardless.
const [allowWarehouseSelection, setAllowWarehouseSelection] = useState(true) const [allowWarehouseSelection, setAllowWarehouseSelection] = useState(true)
const [defaultWarehouseId, setDefaultWarehouseId] = useState<number | null>(null) 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(() => { useEffect(() => {
if (!Number.isFinite(invoiceId)) { if (!Number.isFinite(invoiceId)) {
@@ -118,6 +138,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setAllowDiscounts(config?.allowDiscounts ?? true) setAllowDiscounts(config?.allowDiscounts ?? true)
setAllowWarehouseSelection(!config?.defaultWarehouseId && (config?.allowWarehouseSelection ?? true)) setAllowWarehouseSelection(!config?.defaultWarehouseId && (config?.allowWarehouseSelection ?? true))
setDefaultWarehouseId(config?.defaultWarehouseId ?? null) setDefaultWarehouseId(config?.defaultWarehouseId ?? null)
setCashCustomerId(config?.cashCustomerId ?? null)
setCustomers(customerRes.items) setCustomers(customerRes.items)
setItems(itemRes.items) setItems(itemRes.items)
setUoms(uomRes.items) setUoms(uomRes.items)
@@ -129,8 +150,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
setCustomerId(doc.data.customerId) setCustomerId(doc.data.customerId)
setWarehouseId(doc.data.warehouseId) setWarehouseId(doc.data.warehouseId)
setInvoiceType(doc.data.invoiceType) setInvoiceType(doc.data.invoiceType)
setLines( const loadedLines: Line[] = doc.data.lines.map((line) => ({
doc.data.lines.map((line) => ({
key: String(line.salesInvoiceLineId), key: String(line.salesInvoiceLineId),
itemId: line.itemId, itemId: line.itemId,
warehouseId: line.warehouseId, warehouseId: line.warehouseId,
@@ -146,7 +166,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
isFreeIssue: line.isFreeIssue, isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId, parentLineId: line.parentLineId,
})) }))
) setLines(loadedLines)
setSavedSnapshot(draftSnapshot(doc.data.customerId, doc.data.warehouseId, doc.data.invoiceType, loadedLines))
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
}, [invoiceId, resolvedParams.id]) }, [invoiceId, resolvedParams.id])
@@ -184,6 +205,21 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
} }
const isDraft = invoice?.status === "Draft" 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 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]) 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), // 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))) setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
} }
async function save() { /** Saves the draft's edits; true when they were stored. `quiet` skips the toast (Approve's first step). */
if (!customerId || !warehouseId || !etag || !invoice) return async function save(quiet = false): Promise<boolean> {
if (!customerId || !warehouseId || !etag || !invoice) return false
if (lines.some((line) => !line.itemId || !line.warehouseId)) { if (lines.some((line) => !line.itemId || !line.warehouseId)) {
setError("Select item, UOM and warehouse for every line.") setError("Select item, UOM and warehouse for every line.")
return return false
} }
setSaving(true) setSaving(true)
@@ -278,25 +315,37 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
) )
setInvoice(updated.data) setInvoice(updated.data)
setEtag(updated.etag) 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) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
return false
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
// Approve = save the draft's current edits, then post it. Only an approved invoice takes payments.
async function post() { async function post() {
if (!postingCheck?.canPost) { if (!postingCheck?.canPost) {
setError("Resolve stock shortages before posting this invoice.") setError("Resolve stock shortages before approving this invoice.")
return return
} }
setBusy("post") setBusy("post")
if (isDirty && !(await save(true))) {
setBusy(null)
return
}
setError(null) setError(null)
try { try {
const posted = await salesApi.postInvoice(invoiceId) const posted = await salesApi.postInvoice(invoiceId)
setInvoice(posted) setInvoice(posted)
toast.success("Invoice posted", posted.invoiceNo) toast.success("Invoice approved", posted.invoiceNo)
router.refresh() router.refresh()
} catch (err) { } catch (err) {
setError(errorMessage(err)) setError(errorMessage(err))
@@ -387,7 +436,64 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
</div> </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>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</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> <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> </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"> <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"> <table className="w-full text-sm">
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}> <thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
<tr> <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="mt-6 border-t pt-5 print:hidden">
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold">Actions</h3> <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> </div>
{isDraft ? ( {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"> <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" /> <Save className="size-4" />
{saving ? "Saving..." : "Save invoice"} {saving && busy === null ? "Saving..." : "Save as Draft"}
</button> </button>
{postingCheck && !postingCheck.canPost ? ( {postingCheck && !postingCheck.canPost ? (
<Link <Link
@@ -558,151 +742,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
) : null} ) : 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"> <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" /> <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>
<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"> <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" /> <X className="size-4" />
{busy === "cancel" ? "Cancelling..." : "Cancel invoice"} {busy === "cancel" ? "Cancelling..." : "Cancel invoice"}
</button> </button>
</div> </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"> <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 [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null) 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) const [scanError, setScanError] = useState<string | null>(null)
// Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price. // Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price.
const [resolvedPrices, setResolvedPrices] = useState<Record<string, number>>({}) const [resolvedPrices, setResolvedPrices] = useState<Record<string, number>>({})
@@ -159,9 +160,16 @@ function NewSalesInvoiceContent() {
const w = id === null ? undefined : warehouses.find((x) => x.warehouseId === id) const w = id === null ? undefined : warehouses.find((x) => x.warehouseId === id)
return w ? `${w.code} — ${w.name}` : null 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( 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 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. // 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. // branch to pin to and the Admin must pick one explicitly for this sale.
const lockedBranchId = isAdmin ? activeBranchId : myBranchId const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
const visibleWarehouses = useMemo( const visibleWarehouses = useMemo(
() => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)), () => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)),
[warehouses, effectiveBranchId] [warehouses, effectiveBranchId]
@@ -262,6 +269,12 @@ function NewSalesInvoiceContent() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleWarehouses]) }, [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 { function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null {
return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null
} }
@@ -667,7 +680,9 @@ function NewSalesInvoiceContent() {
const netTotal = Math.max(0, grossTotal - discountTotal) const netTotal = Math.max(0, grossTotal - discountTotal)
const payableTotal = netTotal + taxTotal 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 (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.") if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
if (lines.length === 0) return setSubmitError("Add at least one item.") if (lines.length === 0) return setSubmitError("Add at least one item.")
@@ -701,12 +716,25 @@ function NewSalesInvoiceContent() {
})), })),
} }
setSaving(true) setSaving(mode)
setSubmitError(null) setSubmitError(null)
try { try {
const created = await salesApi.createInvoice(payload) const created = await salesApi.createInvoice(payload)
toast.success("Invoice created", created.data.invoiceNo) const detailUrl = `/dashboard/sales/invoices/${created.data.salesInvoiceId}`
router.push(`/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) { } catch (err) {
if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) { if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) {
router.push(`/login?next=/dashboard/sales/invoices/new`) router.push(`/login?next=/dashboard/sales/invoices/new`)
@@ -714,7 +742,7 @@ function NewSalesInvoiceContent() {
} }
setSubmitError(errorMessage(err)) setSubmitError(errorMessage(err))
} finally { } 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")}> <Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
Cancel Cancel
</Link> </Link>
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}> <Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => submit("draft")} disabled={saving !== null}>
{saving ? "Creating…" : "Create Sale"} {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> </Button>
</div> </div>
</div> </div>
@@ -2,8 +2,8 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation"
import { import {
AlertTriangle,
ArrowDown, ArrowDown,
ArrowUp, ArrowUp,
ArrowUpDown, ArrowUpDown,
@@ -11,23 +11,20 @@ import {
CheckCircle2, CheckCircle2,
CircleDollarSign, CircleDollarSign,
FileText, FileText,
ListFilter,
Loader2, Loader2,
Plus, Plus,
Printer,
Receipt, Receipt,
Tag, Tag,
} from "lucide-react" } from "lucide-react"
import { salesApi } from "@/lib/api/sales" import { salesApi } from "@/lib/api/sales"
import { salesConfigApi } from "@/lib/api/sales-config"
import { customersApi } from "@/lib/api/customers" import { customersApi } from "@/lib/api/customers"
import { warehousesApi } from "@/lib/api/warehouses" import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map" import { errorMessage } from "@/lib/error-map"
import { useAuth } from "@/components/auth/AuthProvider" import { useAuth } from "@/components/auth/AuthProvider"
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { DateRangePicker } from "@/components/ui/date-picker"
import { FilterBar } from "@/components/ui/filter-bar" import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer" import { PaginationFooter } from "@/components/ui/pagination-footer"
import { Field, FieldLabel } from "@/components/ui/field" 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 { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast" 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 { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common" import { PaginationMeta } from "@/types/common"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales" import { SalesInvoiceSummary } from "@/types/sales"
import { Customer } from "@/types/customers" import { Customer } from "@/types/customers"
import { Warehouse } from "@/types/master-data" import { Warehouse } from "@/types/master-data"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All" type SortKey = "invoiceNo" | "customer" | "date" | "due" | "gross" | "discount" | "net"
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "net" | "paid" | "remaining"
type SortOrder = "asc" | "desc" type SortOrder = "asc" | "desc"
/** Same clickable sort header as the Items table. */ /** Same clickable sort header as the Items table. */
@@ -68,13 +61,15 @@ function formatAmount(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) 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 }[] = [ const dateRanges: { value: DateRange; label: string; days: number | null }[] = [
{ value: "All", label: "All time", days: null }, { value: "All", label: "All time", days: null },
{ value: "Today", label: "Today", days: 1 }, { value: "Today", label: "Today", days: 1 },
{ value: "7d", label: "7 days", days: 7 }, { value: "7d", label: "7 days", days: 7 },
{ value: "30d", label: "30 days", days: 30 }, { 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. */ /** 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")}` 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. */ /** Inclusive range ending today: "7 days" is today plus the 6 days before it. A custom range with
function dateRangeBounds(range: DateRange): { from?: string; to?: string } { * 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 const days = dateRanges.find((r) => r.value === range)?.days
if (!days) return {} if (!days) return {}
const today = new Date() const today = new Date()
@@ -95,12 +95,6 @@ function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
// Mobile has no page-size selector — tapping "Show more" just grows the page size by the // 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. // 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 /** 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. */ * 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 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() { export default function SalesInvoicesPage() {
const { activeBranchId, branchingEnabled } = useAuth() const { activeBranchId, branchingEnabled } = useAuth()
const stats = useDashboardStats()
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null) const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null) const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [dateRange, setDateRange] = useState<DateRange>("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 [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
@@ -135,25 +125,19 @@ export default function SalesInvoicesPage() {
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1]) const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
const [showMoreLoading, setShowMoreLoading] = useState(false) const [showMoreLoading, setShowMoreLoading] = useState(false)
const [confirmingId, setConfirmingId] = useState<number | null>(null) const [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null) // This screen lists drafts only; the row, its invoice no. and the invoice icon all open the
const [payDialogOpen, setPayDialogOpen] = useState(false) // invoice page, where a draft is editable.
// The invoice open in the pop-up view (row click, invoice no. or the print icon). Read through const router = useRouter()
// `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
// Multi-invoice payment: ticked rows (kept across pages), all for one customer. The generic cash function openDraft(row: SalesInvoiceSummary) {
// customer's invoices can't be ticked — they're paid one at a time. router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map()) }
const [bulkPayOpen, setBulkPayOpen] = useState(false)
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null) // 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) 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 [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
const [customers, setCustomers] = useState<Customer[]>([]) const [customers, setCustomers] = useState<Customer[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([]) const [warehouses, setWarehouses] = useState<Warehouse[]>([])
@@ -166,13 +150,12 @@ export default function SalesInvoicesPage() {
useEffect(() => { useEffect(() => {
setPage(1) setPage(1)
setPageSize(rowsPerPage) setPageSize(rowsPerPage)
}, [query, status, dateRange, activeBranchId, listCustomerId, warehouseId, rowsPerPage]) setSelected(new Set())
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
useEffect(() => { useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([])) 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(() => { useEffect(() => {
@@ -181,11 +164,13 @@ export default function SalesInvoicesPage() {
.listInvoices({ .listInvoices({
page, page,
pageSize, 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, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId: listCustomerId, customerId,
warehouseId, warehouseId,
...dateRangeBounds(dateRange), from: dateFrom,
to: dateTo,
}) })
.then((res) => { .then((res) => {
setRows(res.items) setRows(res.items)
@@ -193,7 +178,7 @@ export default function SalesInvoicesPage() {
}) })
.catch((err) => setError(errorMessage(err))) .catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false)) .finally(() => setShowMoreLoading(false))
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, listCustomerId, warehouseId]) }, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId])
function handleShowMore() { function handleShowMore() {
setShowMoreLoading(true) setShowMoreLoading(true)
@@ -204,9 +189,14 @@ export default function SalesInvoicesPage() {
setConfirmingId(row.salesInvoiceId) setConfirmingId(row.salesInvoiceId)
try { try {
const posted = await salesApi.postInvoice(row.salesInvoiceId) 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) toast.success("Invoice confirmed", posted.invoiceNo)
setViewRefresh((n) => n + 1)
} catch (err) { } catch (err) {
toast.error("Could not confirm invoice", errorMessage(err)) toast.error("Could not confirm invoice", errorMessage(err))
} finally { } finally {
@@ -214,81 +204,38 @@ export default function SalesInvoicesPage() {
} }
} }
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) { // No bulk endpoint — each draft is approved in turn (each posts its own stock + GL), so one that
setViewRefresh((n) => n + 1) // fails (e.g. short stock) stays ticked in the list while the rest go through.
setRows( async function handleBulkApprove() {
(prev) => const ids = [...selected]
prev?.map((r) => setBulkApproving(true)
r.salesInvoiceId === payment.salesInvoiceId const approved: number[] = []
? { const failed: string[] = []
...r, for (const id of ids) {
status: newStatus, const row = rows?.find((r) => r.salesInvoiceId === id)
totals: { ...r.totals, paidAmount: r.totals.paidAmount + payment.amount, balanceAmount: r.totals.balanceAmount - payment.amount }, try {
await salesApi.postInvoice(id)
approved.push(id)
} catch (err) {
failed.push(`${row?.invoiceNo ?? id}: ${errorMessage(err)}`)
} }
: r }
) ?? prev setRows((prev) => prev?.filter((r) => !approved.includes(r.salesInvoiceId)) ?? prev)
) setSelected((prev) => new Set([...prev].filter((id) => !approved.includes(id))))
// Keep a ticked invoice's balance current, and untick it once nothing is left to pay. 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) => { setSelected((prev) => {
const sel = prev.get(payment.salesInvoiceId) const next = new Set(prev)
if (!sel) return prev if (next.has(id)) next.delete(id)
const next = new Map(prev) else next.add(id)
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 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 // Desktop table sorting (Items-table style) — over the loaded page, client-side; null keeps the
// server's order. // server's order.
const [sortKey, setSortKey] = useState<SortKey | null>(null) const [sortKey, setSortKey] = useState<SortKey | null>(null)
@@ -316,9 +263,9 @@ export default function SalesInvoicesPage() {
case "customer": return r.customerSnapshotName case "customer": return r.customerSnapshotName
case "date": return new Date(r.createdAt).getTime() case "date": return new Date(r.createdAt).getTime()
case "due": return new Date(r.invoiceDate).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 "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) => { return [...visibleRows].sort((a, b) => {
@@ -328,20 +275,28 @@ export default function SalesInvoicesPage() {
}) })
}, [visibleRows, sortKey, sortOrder]) }, [visibleRows, sortKey, sortOrder])
// Header "select all": ticks every tickable row on screen, but only when they're all one customer // Header "select all" ticks every draft on screen (the loaded page, after search).
// (e.g. once a selection has narrowed the list) — invoices of different customers never mix. const allTicked = sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.salesInvoiceId))
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) { function toggleAll(checked: boolean) {
setSelected((prev) => { setSelected((prev) => {
const next = new Map(prev) const next = new Set(prev)
tickable.forEach((r) => (checked ? next.set(r.salesInvoiceId, r) : next.delete(r.salesInvoiceId))) sortedRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId)))
return next 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 grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0) const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0) const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
@@ -361,28 +316,26 @@ export default function SalesInvoicesPage() {
</div> </div>
</div> </div>
{/* Totals are over the invoices currently listed (filters + loaded page). Passed as {/* Same order as the table: how many drafts, then Gross → Discount → Net. Draft count is every
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */} 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"> <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 ? ( {rows !== null ? (
<> <>
<StatCard label="Draft invoices" value={pagination?.totalItems ?? rows.length} icon={FileText} />
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} /> <StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
<StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} /> <StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} />
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} /> <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>
<div className="rounded-2xl border bg-card shadow-sm"> <div className="rounded-2xl border bg-card shadow-sm">
<FilterBar <FilterBar
className="border-b px-4 py-4" className="border-b px-4 py-4"
searchFirst
search={searchInput} search={searchInput}
onSearchChange={setSearchInput} onSearchChange={setSearchInput}
searchPlaceholder="Filter by customer or invoice number" searchPlaceholder="Filter by customer or invoice number"
@@ -393,9 +346,8 @@ export default function SalesInvoicesPage() {
<Field> <Field>
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel> <FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
<Select <Select
value={listCustomerId ? String(listCustomerId) : "All"} value={customerId ? String(customerId) : "All"}
onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)} onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}
disabled={selectedCustomerId !== null}
> >
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger> <SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -418,22 +370,6 @@ export default function SalesInvoicesPage() {
} }
> >
<div className="flex flex-wrap items-center gap-2"> <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> <Select<DateRange>
value={dateRange} value={dateRange}
onValueChange={(v) => setDateRange(v ?? "All")} onValueChange={(v) => setDateRange(v ?? "All")}
@@ -449,26 +385,25 @@ export default function SalesInvoicesPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{dateRange === "Custom" && (
<DateRangePicker value={customRange} onChange={setCustomRange} placeholder="Pick from – to dates" className="sm:w-72" />
)}
</div> </div>
</FilterBar> </FilterBar>
{selected.size > 0 && ( {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"> <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"> <p className="text-sm">
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "} <span className="font-semibold">{selected.size}</span> draft{selected.size === 1 ? "" : "s"} selected
<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> </p>
<div className="flex gap-2"> <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 Clear
</Button> </Button>
<Button type="button" size="sm" onClick={() => setBulkPayOpen(true)}> <Button type="button" size="sm" disabled={bulkApproving} onClick={handleBulkApprove}>
<CircleDollarSign className="size-4" /> {bulkApproving ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
Pay selected {bulkApproving ? "Approving…" : "Approve selected"}
</Button> </Button>
</div> </div>
</div> </div>
@@ -495,6 +430,15 @@ export default function SalesInvoicesPage() {
<> <>
{/* Mobile: stacked cards. */} {/* Mobile: stacked cards. */}
<div className="flex flex-col gap-3 px-4 py-4 md:hidden"> <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) => ( {visibleRows.map((row) => (
<div <div
key={row.salesInvoiceId} key={row.salesInvoiceId}
@@ -502,10 +446,10 @@ export default function SalesInvoicesPage() {
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
setViewInvoice(row) openDraft(row)
}} }}
onKeyDown={(e) => { 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" className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
> >
@@ -538,22 +482,17 @@ export default function SalesInvoicesPage() {
</p> </p>
</div> </div>
<div> <div>
<p className="text-muted-foreground">Gross / Discount</p> <p className="text-muted-foreground">Gross</p>
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</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>
<div> <div>
<p className="text-muted-foreground">Net</p> <p className="text-muted-foreground">Net</p>
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p> <p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
</div> </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>
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav> <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" type="button"
variant="outline" variant="outline"
size="icon" size="icon"
onClick={() => setViewInvoice(row)} onClick={() => openDraft(row)}
aria-label={`Print ${row.invoiceNo}`} aria-label={`Open ${row.invoiceNo} to edit`}
title="Print" title="Open draft to edit"
> >
<Printer className="size-4" /> <FileText className="size-4" />
</Button> </Button>
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
@@ -580,21 +519,6 @@ export default function SalesInvoicesPage() {
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />} {confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button> </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>
</div> </div>
))} ))}
@@ -613,20 +537,20 @@ export default function SalesInvoicesPage() {
<TableRow> <TableRow>
<TableHead className="h-12 w-10 px-3"> <TableHead className="h-12 w-10 px-3">
<Checkbox <Checkbox
checked={canSelectAll && allTicked} checked={allTicked}
disabled={!canSelectAll} disabled={bulkApproving}
onCheckedChange={(c) => toggleAll(c === true)} onCheckedChange={(c) => toggleAll(c === true)}
aria-label="Select all" aria-label="Select all"
title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"} title="Select all"
/> />
</TableHead> </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="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="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="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-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="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">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead> <TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow> </TableRow>
@@ -638,7 +562,7 @@ export default function SalesInvoicesPage() {
className="cursor-pointer" className="cursor-pointer"
onClick={(e) => { onClick={(e) => {
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return 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> <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}`} href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
className="hover:underline" className="hover:underline"
data-no-row-nav 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} {row.invoiceNo}
</Link> </Link>
@@ -672,17 +590,9 @@ export default function SalesInvoicesPage() {
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}> <TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
{new Date(row.invoiceDate).toLocaleDateString()} {new Date(row.invoiceDate).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums"> <TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<p className="font-semibold">{row.totals.grandTotal.toFixed(2)}</p> <TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
<p className="text-xs text-muted-foreground"> <TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
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"> <TableCell className="px-3 py-3.5">
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<SalesInvoiceStatusBadge status={row.status} /> <SalesInvoiceStatusBadge status={row.status} />
@@ -697,11 +607,11 @@ export default function SalesInvoicesPage() {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
onClick={() => setViewInvoice(row)} onClick={() => openDraft(row)}
aria-label={`Print ${row.invoiceNo}`} aria-label={`Open ${row.invoiceNo} to edit`}
title="Print" title="Open draft to edit"
> >
<Printer className="size-4" /> <FileText className="size-4" />
</Button> </Button>
{row.status === "Draft" && ( {row.status === "Draft" && (
<Button <Button
@@ -716,21 +626,6 @@ export default function SalesInvoicesPage() {
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />} {confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
</Button> </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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -752,56 +647,6 @@ export default function SalesInvoicesPage() {
</> </>
)} )}
</div> </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> </div>
) )
} }
+3
View File
@@ -13,6 +13,9 @@ export interface ListCustomersParams {
/** The system cash customer (walk-in / one-time buyers) is left out by default — pass true only /** 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. */ * where the list is used to resolve names for existing documents. */
includeCashCustomer?: boolean 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 = { export const customersApi = {
+6
View File
@@ -35,6 +35,12 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams {
/** yyyy-MM-dd, inclusive; filters on InvoiceDate. */ /** yyyy-MM-dd, inclusive; filters on InvoiceDate. */
from?: string from?: string
to?: 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 { export interface ListSalesSlipsParams extends SalesListFilterParams {
+2
View File
@@ -51,6 +51,8 @@ export interface SalesInvoiceSummary {
totals: SalesInvoiceTotals totals: SalesInvoiceTotals
createdAt: string createdAt: string
glJournalNo: string | null glJournalNo: string | null
/** UserId who created it — Day End's cashier. */
createdBy: number
} }
export interface SalesInvoicePostingIssue { export interface SalesInvoicePostingIssue {