Dev #98
@@ -24,8 +24,9 @@ public sealed class CustomersController : ApiControllerBase
|
||||
[FromQuery] EntityStatus? status,
|
||||
[FromQuery] CustomerType? customerType,
|
||||
CancellationToken ct,
|
||||
[FromQuery] bool includeCashCustomer = false)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, ct));
|
||||
[FromQuery] bool includeCashCustomer = false,
|
||||
[FromQuery] int? branchId = null)
|
||||
=> Ok(await _customers.ListAsync(query, status, customerType, includeCashCustomer, branchId, ct));
|
||||
|
||||
[HttpGet("{customerId:int}")]
|
||||
[ProducesResponseType(typeof(CustomerDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -90,8 +90,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
||||
[FromQuery] int? branchId,
|
||||
[FromQuery] DateOnly? from,
|
||||
[FromQuery] DateOnly? to,
|
||||
[FromQuery] int? createdBy,
|
||||
[FromQuery] bool? paid,
|
||||
CancellationToken ct)
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
|
||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct));
|
||||
|
||||
[HttpGet("{salesInvoiceId:int}")]
|
||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed record SalesInvoiceDto(
|
||||
public sealed record SalesInvoiceSummaryDto(
|
||||
int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId,
|
||||
string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType,
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo);
|
||||
SalesInvoiceStatus Status, SalesInvoiceTotalsDto Totals, DateTime CreatedAt, string? GlJournalNo, int CreatedBy);
|
||||
|
||||
public sealed record SalesInvoicePaymentDto(
|
||||
int SalesInvoicePaymentId, int SalesInvoiceId, decimal Amount, DateTime PaymentDate,
|
||||
|
||||
@@ -34,19 +34,23 @@ public sealed class CustomerService : ICustomerService
|
||||
}
|
||||
|
||||
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 cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
// The generic cash customer is a system default for walk-in / one-time buyers, not a registered
|
||||
// customer — kept out of customer lists unless a caller asks for it (e.g. to resolve names).
|
||||
if (!includeCashCustomer)
|
||||
{
|
||||
var cashCustomerId = await _salesConfig.Query().AsNoTracking()
|
||||
.Where(c => c.ConfigId == SalesConfig.SingletonId)
|
||||
.Select(c => c.CashCustomerId)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
q = q.Where(c => c.CustomerCode != CashCustomerCode && (cashCustomerId == null || c.CustomerId != cashCustomerId));
|
||||
}
|
||||
// Branch-wise, same as the Sales / GRN lists: a customer carries the BranchId it was created
|
||||
// under. The cash customer is shared by every branch, so it is never filtered out here.
|
||||
if (branchId is not null)
|
||||
q = q.Where(c => c.BranchId == branchId
|
||||
|| c.CustomerCode == CashCustomerCode
|
||||
|| (cashCustomerId != null && c.CustomerId == cashCustomerId));
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
{
|
||||
var term = query.Q.Trim();
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
public interface ICustomerService
|
||||
{
|
||||
/// <summary>Registered customers; the system cash customer is left out unless <paramref name="includeCashCustomer"/>.</summary>
|
||||
Task<PagedResponse<CustomerDto>> ListAsync(PageQuery query, EntityStatus? status, CustomerType? customerType, bool includeCashCustomer = false, 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>> CreateAsync(CreateCustomerRequest request, CancellationToken ct = default);
|
||||
Task<ETagged<CustomerDto>> UpdateAsync(int customerId, UpdateCustomerRequest request, uint expectedRowVersion, CancellationToken ct = default);
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
public interface ISalesInvoiceService
|
||||
{
|
||||
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, 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<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_uow = uow;
|
||||
}
|
||||
|
||||
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, 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);
|
||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||
@@ -67,6 +67,11 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
|
||||
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||
// Invoices have no CashierUserId — Day End matches them to a cashier by CreatedBy.
|
||||
if (createdBy is not null) q = q.Where(x => x.CreatedBy == createdBy);
|
||||
// paid=true: nothing left to pay; paid=false: a balance is still due.
|
||||
if (paid == true) q = q.Where(x => x.BalanceAmount <= 0);
|
||||
if (paid == false) q = q.Where(x => x.BalanceAmount > 0);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct);
|
||||
@@ -224,5 +229,5 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
|
||||
private SalesInvoiceSummaryDto MapSummary(SalesInvoice x) => new(
|
||||
x.SalesInvoiceId, x.InvoiceNo, x.InvoiceDate, x.CustomerId, x.CustomerSnapshotName,
|
||||
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo);
|
||||
x.WarehouseId, x.BranchId, x.InvoiceType, x.Status, _mapping.MapInvoiceTotals(x), x.CreatedAt, x.GlJournalNo, x.CreatedBy);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,11 @@ export default function CustomersPage() {
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status, customerType])
|
||||
// Branch-wise list, same as Sales / GRN: the header's active branch (a non-admin's own branch);
|
||||
// an Admin on "All Branches" sees every branch's customers.
|
||||
const listBranchId = branchingEnabled ? (activeBranchId ?? undefined) : undefined
|
||||
|
||||
useEffect(() => setPage(1), [query, status, customerType, listBranchId])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
@@ -90,6 +94,7 @@ export default function CustomersPage() {
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
customerType: customerType === "All" ? undefined : customerType,
|
||||
branchId: listBranchId,
|
||||
page,
|
||||
pageSize,
|
||||
})
|
||||
@@ -106,7 +111,7 @@ export default function CustomersPage() {
|
||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
||||
}
|
||||
|
||||
useEffect(load, [query, status, customerType, page, pageSize])
|
||||
useEffect(load, [query, status, customerType, listBranchId, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
// Every code in use, the hidden cash customer's included, so a generated code never collides.
|
||||
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Lock, ReceiptText } from "lucide-react"
|
||||
import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { salesDayEndApi } from "@/lib/api/sales-day-end"
|
||||
import { usersApi } from "@/lib/api/users"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { ManagedUser } from "@/types/users"
|
||||
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview } from "@/types/sales"
|
||||
import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales"
|
||||
|
||||
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
@@ -23,6 +32,51 @@ function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
type SalesDateRange = "All" | "Today" | "7d" | "30d"
|
||||
|
||||
const salesDateRanges: { value: SalesDateRange; label: string; days: number | null }[] = [
|
||||
{ value: "All", label: "All time", days: null },
|
||||
{ value: "Today", label: "Today", days: 1 },
|
||||
{ value: "7d", label: "7 days", days: 7 },
|
||||
{ value: "30d", label: "30 days", days: 30 },
|
||||
]
|
||||
|
||||
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
|
||||
function toLocalIsoDate(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Inclusive range ending today ("7 days" is today plus the 6 days before it) — same as the Sales page. */
|
||||
function salesDateBounds(range: SalesDateRange): { from?: string; to?: string } {
|
||||
const days = salesDateRanges.find((r) => r.value === range)?.days
|
||||
if (!days) return {}
|
||||
const today = new Date()
|
||||
const from = new Date(today)
|
||||
from.setDate(today.getDate() - (days - 1))
|
||||
return { from: toLocalIsoDate(from), to: toLocalIsoDate(today) }
|
||||
}
|
||||
|
||||
/** Past its due date (`invoiceDate`) with money still owed — compared by calendar day, so an
|
||||
* invoice due today is not overdue. Same rule as the Sales page. */
|
||||
function isOverdue(row: SalesInvoiceSummary): boolean {
|
||||
if (row.status !== "Posted" || row.totals.balanceAmount <= 0) return false
|
||||
const due = new Date(row.invoiceDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
due.setHours(0, 0, 0, 0)
|
||||
return due < today
|
||||
}
|
||||
|
||||
const SALES_PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
|
||||
type PaymentFilter = "All" | "Unpaid" | "Paid"
|
||||
|
||||
const paymentFilters: { value: PaymentFilter; label: string }[] = [
|
||||
{ value: "All", label: "All payments" },
|
||||
{ value: "Unpaid", label: "Balance due" },
|
||||
{ value: "Paid", label: "Fully paid" },
|
||||
]
|
||||
|
||||
export default function SalesDayEndPage() {
|
||||
const [cashiers, setCashiers] = useState<ManagedUser[]>([])
|
||||
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
|
||||
@@ -35,6 +89,49 @@ export default function SalesDayEndPage() {
|
||||
const [report, setReport] = useState<SalesDayEnd | null>(null)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
// Approved (Posted) sales invoices, shown as a table with its own filters (independent of the
|
||||
// business date above): a cashier (null follows the cashier picked above, "All" shows every
|
||||
// cashier's sales) and a date range.
|
||||
const [salesCashier, setSalesCashier] = useState<number | "All" | null>(null)
|
||||
const effectiveSalesCashier = salesCashier ?? cashierUserId
|
||||
const [salesDateRange, setSalesDateRange] = useState<SalesDateRange>("Today")
|
||||
const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange)
|
||||
const [salesPayment, setSalesPayment] = useState<PaymentFilter>("All")
|
||||
const [salesSearchInput, setSalesSearchInput] = useState("")
|
||||
const [salesQuery, setSalesQuery] = useState("")
|
||||
// Server-side paging — every filter above is applied by the API, so pages and counts are exact.
|
||||
const [salesPage, setSalesPage] = useState(1)
|
||||
const [salesPageSize, setSalesPageSize] = useState(SALES_PAGE_SIZE_OPTIONS[1])
|
||||
const [salesReload, setSalesReload] = useState(0)
|
||||
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
|
||||
const salesKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize, salesReload].join("|")
|
||||
const [salesResult, setSalesResult] = useState<{
|
||||
key: string
|
||||
rows: SalesInvoiceSummary[] | null
|
||||
pagination: PaginationMeta | null
|
||||
error: string | null
|
||||
} | null>(null)
|
||||
const current = salesResult?.key === salesKey ? salesResult : null
|
||||
const sales = current?.rows ?? null
|
||||
const salesPagination = current?.pagination ?? null
|
||||
const salesError = current?.error ?? null
|
||||
const [viewInvoice, setViewInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
// Read through the loaded rows so the pop-up's Pay button follows payments.
|
||||
const viewRow = viewInvoice ? (sales?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null
|
||||
|
||||
// Invoice payments are taken here, not on the Sales screens. One invoice at a time via Pay, or
|
||||
// several of one customer ticked together — the generic cash customer's are paid one at a time.
|
||||
const [payInvoice, setPayInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [payOpen, setPayOpen] = useState(false)
|
||||
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
|
||||
const [bulkPayOpen, setBulkPayOpen] = useState(false)
|
||||
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway.
|
||||
salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
usersApi
|
||||
.list({ pageSize: 200 })
|
||||
@@ -62,6 +159,71 @@ export default function SalesDayEndPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [cashierUserId, businessDate])
|
||||
|
||||
// Debounced search; a new term starts again from page 1.
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setSalesQuery(salesSearchInput.trim())
|
||||
setSalesPage(1)
|
||||
setSelected(new Map())
|
||||
}, 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [salesSearchInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (effectiveSalesCashier === null) return
|
||||
salesApi
|
||||
.listInvoices({
|
||||
page: salesPage,
|
||||
pageSize: salesPageSize,
|
||||
status: "Posted",
|
||||
createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier,
|
||||
from: salesFrom,
|
||||
to: salesTo,
|
||||
paid: salesPayment === "All" ? undefined : salesPayment === "Paid",
|
||||
q: salesQuery || undefined,
|
||||
})
|
||||
.then((res) => setSalesResult({ key: salesKey, rows: res.items, pagination: res.pagination, error: null }))
|
||||
.catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) }))
|
||||
}, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize])
|
||||
|
||||
/** Re-read the table (after a payment); ticks are cleared since balances changed. */
|
||||
function reloadSales() {
|
||||
setSelected(new Map())
|
||||
setSalesReload((n) => n + 1)
|
||||
}
|
||||
|
||||
// Any change to what the table lists goes back to page 1 and clears the ticks.
|
||||
function resetSalesView() {
|
||||
setSalesPage(1)
|
||||
setSelected(new Map())
|
||||
}
|
||||
|
||||
function changeCashier(userId: number | null) {
|
||||
setCashierUserId(userId)
|
||||
setSalesCashier(null)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesCashier(value: number | "All") {
|
||||
setSalesCashier(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesDateRange(value: SalesDateRange) {
|
||||
setSalesDateRange(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesPayment(value: PaymentFilter) {
|
||||
setSalesPayment(value)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
function changeSalesPageSize(size: number) {
|
||||
setSalesPageSize(size)
|
||||
resetSalesView()
|
||||
}
|
||||
|
||||
async function handleClose() {
|
||||
if (!cashierUserId) return
|
||||
setClosing(true)
|
||||
@@ -79,6 +241,26 @@ export default function SalesDayEndPage() {
|
||||
|
||||
const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName
|
||||
|
||||
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
|
||||
|
||||
/** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */
|
||||
function notSelectableReason(row: SalesInvoiceSummary): string | null {
|
||||
if (selected.has(row.salesInvoiceId)) return null
|
||||
if (row.totals.balanceAmount <= 0) return "Already fully paid"
|
||||
if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time"
|
||||
if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only"
|
||||
return null
|
||||
}
|
||||
|
||||
function toggleSelected(row: SalesInvoiceSummary) {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId)
|
||||
else next.set(row.salesInvoiceId, row)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -96,7 +278,7 @@ export default function SalesDayEndPage() {
|
||||
<div className="grid grid-cols-1 gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)] sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Cashier</Label>
|
||||
<Select<number | null> value={cashierUserId} onValueChange={setCashierUserId}>
|
||||
<Select<number | null> value={cashierUserId} onValueChange={changeCashier}>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select cashier" />
|
||||
</SelectTrigger>
|
||||
@@ -294,6 +476,373 @@ export default function SalesDayEndPage() {
|
||||
<InvoiceSummaryPanel invoices={report.invoices} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SalesTable
|
||||
rows={sales}
|
||||
error={salesError}
|
||||
cashiers={cashiers}
|
||||
cashierFilter={effectiveSalesCashier}
|
||||
onCashierFilterChange={changeSalesCashier}
|
||||
dateRange={salesDateRange}
|
||||
onDateRangeChange={changeSalesDateRange}
|
||||
paymentFilter={salesPayment}
|
||||
onPaymentFilterChange={changeSalesPayment}
|
||||
search={salesSearchInput}
|
||||
onSearchChange={setSalesSearchInput}
|
||||
pagination={salesPagination}
|
||||
onPageChange={setSalesPage}
|
||||
onPageSizeChange={changeSalesPageSize}
|
||||
onView={setViewInvoice}
|
||||
onPay={(row) => {
|
||||
setPayInvoice(row)
|
||||
setPayOpen(true)
|
||||
}}
|
||||
selected={selected}
|
||||
notSelectableReason={notSelectableReason}
|
||||
onToggle={toggleSelected}
|
||||
onClearSelection={() => setSelected(new Map())}
|
||||
onPaySelected={() => setBulkPayOpen(true)}
|
||||
/>
|
||||
|
||||
{/* The invoice pop-up (View action or row click). Its Pay button follows the reloaded row,
|
||||
and the sheet itself re-reads after a payment via refreshKey. */}
|
||||
<SalesInvoicePrintDialog
|
||||
invoiceId={viewRow?.salesInvoiceId ?? null}
|
||||
invoiceNo={viewRow?.invoiceNo}
|
||||
open={viewRow !== null}
|
||||
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
|
||||
refreshKey={salesReload}
|
||||
actions={
|
||||
viewRow && viewRow.totals.balanceAmount > 0 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPayInvoice(viewRow)
|
||||
setPayOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${viewRow.invoiceNo}`}
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{/* After a payment the table is re-read, so Paid / Remaining come from the server. */}
|
||||
<SalesInvoicePaymentDialog invoice={payInvoice} open={payOpen} onOpenChange={setPayOpen} onPaid={reloadSales} />
|
||||
<SalesInvoiceBulkPaymentDialog
|
||||
invoices={[...selected.values()]}
|
||||
open={bulkPayOpen}
|
||||
onOpenChange={setBulkPayOpen}
|
||||
onConfirmed={() => {}}
|
||||
onPaid={reloadSales}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One page of Approved (Posted) sales invoices. Every filter (cashier or all cashiers, date range,
|
||||
// payment status, search) is applied server-side, so the pager and counts cover all matches.
|
||||
function SalesTable({
|
||||
rows,
|
||||
error,
|
||||
cashiers,
|
||||
cashierFilter,
|
||||
onCashierFilterChange,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
paymentFilter,
|
||||
onPaymentFilterChange,
|
||||
search,
|
||||
onSearchChange,
|
||||
pagination,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onView,
|
||||
onPay,
|
||||
selected,
|
||||
notSelectableReason,
|
||||
onToggle,
|
||||
onClearSelection,
|
||||
onPaySelected,
|
||||
}: {
|
||||
rows: SalesInvoiceSummary[] | null
|
||||
error: string | null
|
||||
cashiers: ManagedUser[]
|
||||
cashierFilter: number | "All" | null
|
||||
onCashierFilterChange: (value: number | "All") => void
|
||||
dateRange: SalesDateRange
|
||||
onDateRangeChange: (value: SalesDateRange) => void
|
||||
paymentFilter: PaymentFilter
|
||||
onPaymentFilterChange: (value: PaymentFilter) => void
|
||||
search: string
|
||||
onSearchChange: (value: string) => void
|
||||
pagination: PaginationMeta | null
|
||||
onPageChange: (page: number) => void
|
||||
onPageSizeChange: (size: number) => void
|
||||
onView: (row: SalesInvoiceSummary) => void
|
||||
onPay: (row: SalesInvoiceSummary) => void
|
||||
selected: Map<number, SalesInvoiceSummary>
|
||||
notSelectableReason: (row: SalesInvoiceSummary) => string | null
|
||||
onToggle: (row: SalesInvoiceSummary) => void
|
||||
onClearSelection: () => void
|
||||
onPaySelected: () => void
|
||||
}) {
|
||||
const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||
|
||||
function renderSelectBox(row: SalesInvoiceSummary) {
|
||||
const reason = notSelectableReason(row)
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selected.has(row.salesInvoiceId)}
|
||||
disabled={reason !== null}
|
||||
title={reason ?? "Select to pay together"}
|
||||
aria-label={`Select ${row.invoiceNo}`}
|
||||
onCheckedChange={() => onToggle(row)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// View opens the invoice pop-up; Pay shows while a balance is still due.
|
||||
function renderActions(row: SalesInvoiceSummary) {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
onClick={() => onView(row)}
|
||||
aria-label={`View ${row.invoiceNo}`}
|
||||
title="View invoice"
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.totals.balanceAmount > 0 && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onPay(row)} aria-label={`Pay ${row.invoiceNo}`}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const showCashier = cashierFilter === "All"
|
||||
const cashierNameById = new Map(cashiers.map((c) => [c.userId, c.displayName]))
|
||||
const visible = rows ?? []
|
||||
const filtered = paymentFilter !== "All" || search.trim().length > 0
|
||||
|
||||
const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0)
|
||||
const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0)
|
||||
const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Sales</h2>
|
||||
<p className="text-sm text-muted-foreground">Approved sales invoices — filter by cashier, date, payment status or search.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Select<number | "All" | null>
|
||||
value={cashierFilter}
|
||||
onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }}
|
||||
items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Cashier">
|
||||
<SelectValue placeholder="Cashier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="All">All cashiers</SelectItem>
|
||||
{cashiers.map((c) => (
|
||||
<SelectItem key={c.userId} value={c.userId}>{c.displayName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<SalesDateRange>
|
||||
value={dateRange}
|
||||
onValueChange={(v) => onDateRangeChange(v ?? "Today")}
|
||||
items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Date range">
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{salesDateRanges.map((r) => (
|
||||
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select<PaymentFilter>
|
||||
value={paymentFilter}
|
||||
onValueChange={(v) => onPaymentFilterChange(v ?? "All")}
|
||||
items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-full text-sm" aria-label="Payment status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentFilters.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder="Search invoice or customer"
|
||||
className="h-10 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">{error}</div>}
|
||||
|
||||
{!error && rows === null && <Skeleton className="h-32 w-full" />}
|
||||
|
||||
{!error && rows !== null && visible.length === 0 && (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||
{filtered ? "No sales match these filters." : "No approved sales in this period."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
|
||||
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
|
||||
<span className="font-semibold tabular-nums">{selectedBalance.toFixed(2)}</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={onClearSelection}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={onPaySelected}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visible.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked rows. */}
|
||||
<div className="flex flex-col gap-1.5 md:hidden">
|
||||
{visible.map((row) => (
|
||||
<div key={row.salesInvoiceId} className="flex items-center gap-3 rounded-lg border p-3 text-sm">
|
||||
{renderSelectBox(row)}
|
||||
<button type="button" onClick={() => onView(row)} className="flex min-w-0 flex-1 items-center justify-between gap-2 text-left">
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-medium">{row.invoiceNo}</span>
|
||||
<span className="block truncate text-muted-foreground">
|
||||
{row.customerSnapshotName}
|
||||
{showCashier && ` · ${cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}`}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{new Date(row.createdAt).toLocaleDateString()} · Due{" "}
|
||||
<span className={cn(isOverdue(row) && "font-semibold text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
<span className="font-mono tabular-nums">{row.totals.grandTotal.toFixed(2)}</span>
|
||||
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||
</span>
|
||||
</button>
|
||||
{renderActions(row)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-10 px-3" />
|
||||
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Customer</TableHead>
|
||||
{showCashier && <TableHead className="h-12 px-3 text-sm">Cashier</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Due</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Net</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Paid</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Remaining</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map((row) => (
|
||||
<TableRow
|
||||
key={row.salesInvoiceId}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
onView(row)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
{renderSelectBox(row)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 font-medium">{row.invoiceNo}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.customerSnapshotName}</TableCell>
|
||||
{showCashier && (
|
||||
<TableCell className="px-3 py-3.5">{cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`}</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"px-3 py-3.5 text-right tabular-nums",
|
||||
row.status !== "Cancelled" && row.totals.balanceAmount > 0 && "font-semibold text-destructive"
|
||||
)}
|
||||
>
|
||||
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<SalesInvoiceStatusBadge status={row.status} />
|
||||
{row.status !== "Draft" && (
|
||||
<SalesPaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
{renderActions(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={onPageChange}
|
||||
pageSizeOptions={SALES_PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Invoices counts every match; the amounts are for the rows on this page. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<Stat label="Invoices" value={pagination?.totalItems ?? visible.length} isCount />
|
||||
<Stat label="Net (this page)" value={netTotal} emphasize />
|
||||
<Stat label="Paid (this page)" value={paidTotal} />
|
||||
<Stat label="Remaining (this page)" value={balanceTotal} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,22 @@ const blankLine = (key: string): Line => ({
|
||||
parentLineId: null,
|
||||
})
|
||||
|
||||
/** Comparable form of the draft's editable fields (line keys excluded) — for "unsaved changes". */
|
||||
function draftSnapshot(customerId: number | null, warehouseId: number | null, invoiceType: SalesInvoiceType, lines: Line[]): string {
|
||||
return JSON.stringify({
|
||||
customerId,
|
||||
warehouseId,
|
||||
invoiceType,
|
||||
lines: lines.map((line) => ({
|
||||
itemId: line.itemId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: Number(line.qty),
|
||||
freeQty: Number(line.freeQty),
|
||||
unitPrice: line.unitPrice ?? null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
/** "Posted" reads as "approved" everywhere in the UI — matches SalesInvoiceStatusBadge's label. */
|
||||
function statusWord(status: SalesInvoiceStatus): string {
|
||||
return status === "Posted" ? "approved" : status.toLowerCase()
|
||||
@@ -77,6 +93,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||
// The draft as last loaded/saved — Save as Draft is enabled only when the form differs from it.
|
||||
const [savedSnapshot, setSavedSnapshot] = useState<string | null>(null)
|
||||
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [invoiceType, setInvoiceType] = useState<SalesInvoiceType>("B2C")
|
||||
@@ -95,6 +113,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
// editor's Warehouse picker with a locked field — the server keeps the invoice on it regardless.
|
||||
const [allowWarehouseSelection, setAllowWarehouseSelection] = useState(true)
|
||||
const [defaultWarehouseId, setDefaultWarehouseId] = useState<number | null>(null)
|
||||
// The shared cash customer — offered on every branch's Bill To picker.
|
||||
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(invoiceId)) {
|
||||
@@ -118,6 +138,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
setAllowDiscounts(config?.allowDiscounts ?? true)
|
||||
setAllowWarehouseSelection(!config?.defaultWarehouseId && (config?.allowWarehouseSelection ?? true))
|
||||
setDefaultWarehouseId(config?.defaultWarehouseId ?? null)
|
||||
setCashCustomerId(config?.cashCustomerId ?? null)
|
||||
setCustomers(customerRes.items)
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
@@ -129,24 +150,24 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
setCustomerId(doc.data.customerId)
|
||||
setWarehouseId(doc.data.warehouseId)
|
||||
setInvoiceType(doc.data.invoiceType)
|
||||
setLines(
|
||||
doc.data.lines.map((line) => ({
|
||||
key: String(line.salesInvoiceLineId),
|
||||
itemId: line.itemId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
freeQty: line.freeQty,
|
||||
unitPrice: line.unitPrice,
|
||||
allowManualPriceOverride: true,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: line.discountPct,
|
||||
discountAmount: line.discountAmount,
|
||||
discountValue: 0,
|
||||
taxPct: line.taxPct,
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
parentLineId: line.parentLineId,
|
||||
}))
|
||||
)
|
||||
const loadedLines: Line[] = doc.data.lines.map((line) => ({
|
||||
key: String(line.salesInvoiceLineId),
|
||||
itemId: line.itemId,
|
||||
warehouseId: line.warehouseId,
|
||||
qty: line.qty,
|
||||
freeQty: line.freeQty,
|
||||
unitPrice: line.unitPrice,
|
||||
allowManualPriceOverride: true,
|
||||
discountMode: line.discountMode,
|
||||
discountPct: line.discountPct,
|
||||
discountAmount: line.discountAmount,
|
||||
discountValue: 0,
|
||||
taxPct: line.taxPct,
|
||||
isFreeIssue: line.isFreeIssue,
|
||||
parentLineId: line.parentLineId,
|
||||
}))
|
||||
setLines(loadedLines)
|
||||
setSavedSnapshot(draftSnapshot(doc.data.customerId, doc.data.warehouseId, doc.data.invoiceType, loadedLines))
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [invoiceId, resolvedParams.id])
|
||||
@@ -184,6 +205,21 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
}
|
||||
|
||||
const isDraft = invoice?.status === "Draft"
|
||||
// Branch-wise Bill To picker: customers of the invoice's own branch, plus the shared cash customer
|
||||
// and whoever the draft is already billed to (so an older cross-branch draft still shows its customer).
|
||||
const branchCustomers = useMemo(
|
||||
() =>
|
||||
customers.filter(
|
||||
(c) =>
|
||||
!invoice ||
|
||||
c.branchId === invoice.branchId ||
|
||||
c.customerId === cashCustomerId ||
|
||||
c.customerCode === "CUST-CASH" ||
|
||||
c.customerId === invoice.customerId,
|
||||
),
|
||||
[customers, invoice, cashCustomerId],
|
||||
)
|
||||
const isDirty = savedSnapshot !== null && draftSnapshot(customerId, warehouseId, invoiceType, lines) !== savedSnapshot
|
||||
const customer = useMemo(() => customers.find((c) => c.customerId === invoice?.customerId), [customers, invoice?.customerId])
|
||||
const warehouse = useMemo(() => warehouses.find((w) => w.warehouseId === invoice?.warehouseId), [warehouses, invoice?.warehouseId])
|
||||
// With selection off, saving moves the draft onto the default sales warehouse (server-forced),
|
||||
@@ -241,11 +277,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!customerId || !warehouseId || !etag || !invoice) return
|
||||
/** Saves the draft's edits; true when they were stored. `quiet` skips the toast (Approve's first step). */
|
||||
async function save(quiet = false): Promise<boolean> {
|
||||
if (!customerId || !warehouseId || !etag || !invoice) return false
|
||||
if (lines.some((line) => !line.itemId || !line.warehouseId)) {
|
||||
setError("Select item, UOM and warehouse for every line.")
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
@@ -278,25 +315,37 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
)
|
||||
setInvoice(updated.data)
|
||||
setEtag(updated.etag)
|
||||
toast.success("Invoice saved", updated.data.invoiceNo)
|
||||
setSavedSnapshot(draftSnapshot(customerId, warehouseId, invoiceType, lines))
|
||||
if (!quiet) {
|
||||
// Save as Draft is done with the invoice — back to the Sales list (drafts table).
|
||||
toast.success("Draft saved", updated.data.invoiceNo)
|
||||
router.push("/dashboard/sales/invoices")
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
return false
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Approve = save the draft's current edits, then post it. Only an approved invoice takes payments.
|
||||
async function post() {
|
||||
if (!postingCheck?.canPost) {
|
||||
setError("Resolve stock shortages before posting this invoice.")
|
||||
setError("Resolve stock shortages before approving this invoice.")
|
||||
return
|
||||
}
|
||||
setBusy("post")
|
||||
if (isDirty && !(await save(true))) {
|
||||
setBusy(null)
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
const posted = await salesApi.postInvoice(invoiceId)
|
||||
setInvoice(posted)
|
||||
toast.success("Invoice posted", posted.invoiceNo)
|
||||
toast.success("Invoice approved", posted.invoiceNo)
|
||||
router.refresh()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
@@ -387,7 +436,64 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 py-5 md:grid-cols-2">
|
||||
{/* A draft reopens in edit mode right here in the invoice body: header fields and lines are
|
||||
editable in place; Save as Draft / Approve invoice sit under Actions. */}
|
||||
{isDraft ? (
|
||||
<div className="grid gap-4 py-5 md:grid-cols-3 print:hidden">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</Label>
|
||||
<select
|
||||
value={customerId ? String(customerId) : ""}
|
||||
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select customer</option>
|
||||
{branchCustomers.map((c) => (
|
||||
<option key={c.customerId} value={String(c.customerId)}>
|
||||
{c.customerCode} - {c.displayName ?? c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</Label>
|
||||
{allowWarehouseSelection ? (
|
||||
<select
|
||||
value={warehouseId ? String(warehouseId) : ""}
|
||||
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select warehouse</option>
|
||||
{warehouses.map((w) => (
|
||||
<option key={w.warehouseId} value={String(w.warehouseId)}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
// Pinned to the default sales warehouse — shown, not editable.
|
||||
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Invoice type</Label>
|
||||
<select
|
||||
value={invoiceType}
|
||||
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
|
||||
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
|
||||
<option value="Cash">Cash</option>
|
||||
<option value="Credit">Credit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn("grid gap-6 py-5 md:grid-cols-2", isDraft && "hidden print:grid")}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
|
||||
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
||||
@@ -401,7 +507,91 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
{isDraft ? (
|
||||
<div className="print:hidden">
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItem(Number(v))}
|
||||
placeholder="Add item — type SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="mb-3 w-full sm:max-w-md"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
<div className="overflow-x-auto border-y">
|
||||
<table className="w-full text-sm">
|
||||
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Item</th>
|
||||
<th className="px-4 py-2 text-left font-medium">UOM</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
|
||||
<th className="px-4 py-2 text-right font-medium">Unit price</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.key} className="border-t border-border align-middle">
|
||||
<td className="min-w-64 px-4 py-3">
|
||||
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
|
||||
</td>
|
||||
<td className="min-w-32 px-4 py-3 text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</td>
|
||||
<td className="w-28 px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
|
||||
aria-label="Qty"
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
{allowFreeItems && (
|
||||
<td className="w-28 px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||
aria-label="Free qty"
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="w-32 px-4 py-3">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.unitPrice ?? ""}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||
aria-label="Unit price"
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono text-sm tabular-nums"
|
||||
/>
|
||||
</td>
|
||||
<td className="w-24 px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(line.key)}
|
||||
disabled={lines.length === 1}
|
||||
aria-label="Remove line"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">Totals below update when the draft is saved.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn("overflow-x-auto border-y", isDraft && "hidden print:block")}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
|
||||
<tr>
|
||||
@@ -529,23 +719,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="mt-6 border-t pt-5 print:hidden">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Actions</h3>
|
||||
<span className="text-sm text-muted-foreground">{isDraft ? "Draft invoice can be edited." : "Only draft invoices are editable."}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isDraft ? "Edit the draft above, then save as draft or approve. Payments open once approved." : "Only draft invoices are editable."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isDraft ? (
|
||||
<>
|
||||
<SuggestInput
|
||||
options={itemOptions}
|
||||
onSelect={(v) => addItem(Number(v))}
|
||||
placeholder="Add item — type SKU or name…"
|
||||
emptyText="No items match."
|
||||
className="mb-4 w-full sm:max-w-md"
|
||||
aria-label="Add item"
|
||||
/>
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<button type="button" onClick={save} disabled={saving} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
|
||||
<button type="button" onClick={() => save()} disabled={!isDirty || saving || busy !== null} title={isDirty ? undefined : "No changes to save"} className="inline-flex h-9 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50">
|
||||
<Save className="size-4" />
|
||||
{saving ? "Saving..." : "Save invoice"}
|
||||
{saving && busy === null ? "Saving..." : "Save as Draft"}
|
||||
</button>
|
||||
{postingCheck && !postingCheck.canPost ? (
|
||||
<Link
|
||||
@@ -558,151 +742,13 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
) : null}
|
||||
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
|
||||
<Send className="size-4" />
|
||||
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
||||
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Approving..." : "Approve invoice"}
|
||||
</button>
|
||||
<button type="button" onClick={cancel} disabled={busy !== null} className="inline-flex h-9 items-center gap-2 rounded-full border border-rose-300 bg-rose-50 px-4 text-sm font-medium text-rose-700 shadow-sm hover:bg-rose-100 disabled:opacity-50">
|
||||
<X className="size-4" />
|
||||
{busy === "cancel" ? "Cancelling..." : "Cancel invoice"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Item</th>
|
||||
<th className="px-4 py-2 text-left font-medium">UOM</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||
{allowFreeItems && <th className="px-4 py-2 text-right font-medium">Free</th>}
|
||||
<th className="px-4 py-2 text-right font-medium">Unit price</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((line) => (
|
||||
<tr key={line.key} className="border-t border-border align-middle">
|
||||
<td className="px-4 py-3 min-w-64">
|
||||
<span className="font-medium text-foreground">{itemLabel(line.itemId)}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 min-w-40 text-sm text-muted-foreground">
|
||||
{baseUomLabel(items, uoms, line.itemId)}
|
||||
</td>
|
||||
<td className="px-4 py-3 w-28">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.qty}
|
||||
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) || 0 })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
</td>
|
||||
{allowFreeItems && (
|
||||
<td className="px-4 py-3 w-28">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="1"
|
||||
value={line.freeQty}
|
||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3 w-32">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={line.unitPrice ?? ""}
|
||||
onChange={(e) => updateLine(line.key, { unitPrice: parsePriceInput(e.target.value) })}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||
/>
|
||||
<div className="mt-1 text-[11px] text-muted-foreground">
|
||||
{getSuggestedUnitPrice(items, line.itemId) !== null
|
||||
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
|
||||
: "No price suggestion available"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 w-24 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeLine(line.key)}
|
||||
disabled={lines.length === 1}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-black bg-white text-sm font-medium shadow-sm hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Minus className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-4 md:grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Customer</Label>
|
||||
<select
|
||||
value={customerId ? String(customerId) : ""}
|
||||
onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select customer</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.customerId} value={String(c.customerId)}>
|
||||
{c.customerCode} - {c.displayName ?? c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Warehouse</Label>
|
||||
{allowWarehouseSelection ? (
|
||||
<select
|
||||
value={warehouseId ? String(warehouseId) : ""}
|
||||
onChange={(e) => updateHeaderWarehouse(e.target.value ? Number(e.target.value) : null)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
<option value="">Select warehouse</option>
|
||||
{warehouses.map((w) => (
|
||||
<option key={w.warehouseId} value={String(w.warehouseId)}>
|
||||
{w.code} - {w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
// Pinned to the default sales warehouse — shown, not editable.
|
||||
<div className="flex h-10 items-center rounded-lg border border-input bg-muted px-3 text-sm text-muted-foreground">
|
||||
{pinnedWarehouse ? `${pinnedWarehouse.code} - ${pinnedWarehouse.name}` : "No default warehouse set"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Invoice type</Label>
|
||||
<select
|
||||
value={invoiceType}
|
||||
onChange={(e) => setInvoiceType(e.target.value as SalesInvoiceType)}
|
||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm"
|
||||
>
|
||||
{/* Only Cash/Credit are offered now; an older B2B/B2C invoice still shows its own type. */}
|
||||
{(invoiceType === "B2B" || invoiceType === "B2C") && <option value={invoiceType}>{invoiceType}</option>}
|
||||
<option value="Cash">Cash</option>
|
||||
<option value="Credit">Credit</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={post}
|
||||
disabled={!canPost}
|
||||
className="inline-flex h-10 items-center gap-2 rounded-full bg-black px-5 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Send className="size-4" />
|
||||
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
@@ -137,7 +137,8 @@ function NewSalesInvoiceContent() {
|
||||
const [bundleTemplates, setBundleTemplates] = useState<BundleSaleTemplateSummary[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
// Which action is in flight — Save as Draft or Approve invoice.
|
||||
const [saving, setSaving] = useState<"draft" | "approve" | null>(null)
|
||||
const [scanError, setScanError] = useState<string | null>(null)
|
||||
// Server-resolved fallback prices ("itemId:warehouseId" -> price) for items with no flat price.
|
||||
const [resolvedPrices, setResolvedPrices] = useState<Record<string, number>>({})
|
||||
@@ -159,9 +160,16 @@ function NewSalesInvoiceContent() {
|
||||
const w = id === null ? undefined : warehouses.find((x) => x.warehouseId === id)
|
||||
return w ? `${w.code} — ${w.name}` : null
|
||||
}
|
||||
// The sale's branch (see the branch picker below). Declared here so the customer list can use it.
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
// Branch-wise, like the warehouses below: only customers created under the sale's branch. The
|
||||
// cash customer is shared by every branch and is added to the dropdown separately.
|
||||
const regularCustomers = useMemo(
|
||||
() => customers.filter((c) => c.customerId !== salesConfig?.cashCustomerId),
|
||||
[customers, salesConfig],
|
||||
() =>
|
||||
customers.filter(
|
||||
(c) => c.customerId !== salesConfig?.cashCustomerId && (effectiveBranchId === null || c.branchId === effectiveBranchId),
|
||||
),
|
||||
[customers, salesConfig, effectiveBranchId],
|
||||
)
|
||||
// The dropdown rows carry extra customer details, so pass explicit trigger labels — otherwise
|
||||
// the Select wrapper derives them from the rows and the closed trigger shows the details too.
|
||||
@@ -247,7 +255,6 @@ function NewSalesInvoiceContent() {
|
||||
// branch to pin to and the Admin must pick one explicitly for this sale.
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null
|
||||
const visibleWarehouses = useMemo(
|
||||
() => (effectiveBranchId === null ? warehouses : warehouses.filter((w) => w.branchId === effectiveBranchId)),
|
||||
[warehouses, effectiveBranchId]
|
||||
@@ -262,6 +269,12 @@ function NewSalesInvoiceContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visibleWarehouses])
|
||||
|
||||
// Same for the customer: one from another branch is cleared when the branch changes (the
|
||||
// shared cash customer always stays valid).
|
||||
const customerOutOfBranch =
|
||||
customerId !== null && customerId !== cashCustomerId && customers.length > 0 && !regularCustomers.some((c) => c.customerId === customerId)
|
||||
if (customerOutOfBranch) setCustomerId(null)
|
||||
|
||||
function freeIssueSchemeFor(itemId: number): FreeIssueScheme | null {
|
||||
return freeIssueSchemes.find((s) => s.itemId === itemId) ?? null
|
||||
}
|
||||
@@ -667,7 +680,9 @@ function NewSalesInvoiceContent() {
|
||||
const netTotal = Math.max(0, grossTotal - discountTotal)
|
||||
const payableTotal = netTotal + taxTotal
|
||||
|
||||
async function submit() {
|
||||
/** "draft" saves the sale as a Draft (still editable); "approve" saves it and approves (posts) it
|
||||
* straight away — only an approved invoice can take payments. */
|
||||
async function submit(mode: "draft" | "approve") {
|
||||
if (!customerId || !warehouseId) return setSubmitError("Select a customer and warehouse.")
|
||||
if (branchingEnabled && !effectiveBranchId) return setSubmitError("Select a branch for this sale.")
|
||||
if (lines.length === 0) return setSubmitError("Add at least one item.")
|
||||
@@ -701,12 +716,25 @@ function NewSalesInvoiceContent() {
|
||||
})),
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
setSaving(mode)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const created = await salesApi.createInvoice(payload)
|
||||
toast.success("Invoice created", created.data.invoiceNo)
|
||||
router.push(`/dashboard/sales/invoices/${created.data.salesInvoiceId}`)
|
||||
const detailUrl = `/dashboard/sales/invoices/${created.data.salesInvoiceId}`
|
||||
if (mode === "approve") {
|
||||
try {
|
||||
const posted = await salesApi.postInvoice(created.data.salesInvoiceId)
|
||||
toast.success("Invoice approved", posted.invoiceNo)
|
||||
} catch (err) {
|
||||
// The sale is saved — it just stays a Draft; its page shows why and can approve it later.
|
||||
toast.error(`Saved as draft ${created.data.invoiceNo} — could not approve`, errorMessage(err))
|
||||
}
|
||||
router.push(detailUrl)
|
||||
} else {
|
||||
// Save as Draft goes straight back to the Sales list (drafts table).
|
||||
toast.success("Draft saved", created.data.invoiceNo)
|
||||
router.push("/dashboard/sales/invoices")
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && "status" in err && (err as { status?: number }).status === 401) {
|
||||
router.push(`/login?next=/dashboard/sales/invoices/new`)
|
||||
@@ -714,7 +742,7 @@ function NewSalesInvoiceContent() {
|
||||
}
|
||||
setSubmitError(errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
setSaving(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1278,8 +1306,11 @@ function NewSalesInvoiceContent() {
|
||||
<Link href="/dashboard/sales/invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto")}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={submit} disabled={saving}>
|
||||
{saving ? "Creating…" : "Create Sale"}
|
||||
<Button size="lg" type="button" variant="outline" className="w-full sm:w-auto" onClick={() => submit("draft")} disabled={saving !== null}>
|
||||
{saving === "draft" ? "Saving…" : "Save as Draft"}
|
||||
</Button>
|
||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={() => submit("approve")} disabled={saving !== null}>
|
||||
{saving === "approve" ? "Approving…" : "Approve invoice"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
@@ -11,23 +11,20 @@ import {
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
FileText,
|
||||
ListFilter,
|
||||
Loader2,
|
||||
Plus,
|
||||
Printer,
|
||||
Receipt,
|
||||
Tag,
|
||||
} from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { salesConfigApi } from "@/lib/api/sales-config"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { useDashboardStats } from "@/hooks/use-dashboard-stats"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { DateRangePicker } from "@/components/ui/date-picker"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
@@ -36,18 +33,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||
import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog"
|
||||
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
||||
import { SalesInvoiceSummary } from "@/types/sales"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type StatusFilter = SalesInvoiceStatus | "All"
|
||||
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "net" | "paid" | "remaining"
|
||||
type SortKey = "invoiceNo" | "customer" | "date" | "due" | "gross" | "discount" | "net"
|
||||
type SortOrder = "asc" | "desc"
|
||||
|
||||
/** Same clickable sort header as the Items table. */
|
||||
@@ -68,13 +61,15 @@ function formatAmount(n: number) {
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
|
||||
type DateRange = "All" | "Today" | "7d" | "30d"
|
||||
type DateRange = "All" | "Today" | "7d" | "30d" | "Custom"
|
||||
type CustomRange = { from: Date | undefined; to?: Date | undefined }
|
||||
|
||||
const dateRanges: { value: DateRange; label: string; days: number | null }[] = [
|
||||
{ value: "All", label: "All time", days: null },
|
||||
{ value: "Today", label: "Today", days: 1 },
|
||||
{ value: "7d", label: "7 days", days: 7 },
|
||||
{ value: "30d", label: "30 days", days: 30 },
|
||||
{ value: "Custom", label: "Custom range", days: null },
|
||||
]
|
||||
|
||||
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
|
||||
@@ -82,8 +77,13 @@ function toIsoDate(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. */
|
||||
function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
|
||||
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. A custom range with
|
||||
* only its start picked so far covers just that day; with nothing picked it doesn't filter. */
|
||||
function dateRangeBounds(range: DateRange, custom: CustomRange | undefined): { from?: string; to?: string } {
|
||||
if (range === "Custom") {
|
||||
if (!custom?.from) return {}
|
||||
return { from: toIsoDate(custom.from), to: toIsoDate(custom.to ?? custom.from) }
|
||||
}
|
||||
const days = dateRanges.find((r) => r.value === range)?.days
|
||||
if (!days) return {}
|
||||
const today = new Date()
|
||||
@@ -95,12 +95,6 @@ function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size by the
|
||||
// chosen rows-per-page and re-fetches from page 1, so loaded cards stay put and more append.
|
||||
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "Draft", label: "Draft" },
|
||||
{ value: "Posted", label: "Approved" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
/** Past its due date (the "Due" column, `invoiceDate`) with money still owed — Draft and Cancelled
|
||||
* invoices are never overdue. Compared by calendar day, so an invoice due today is not overdue. */
|
||||
@@ -113,20 +107,16 @@ function isOverdue(row: SalesInvoiceSummary): boolean {
|
||||
return due < today
|
||||
}
|
||||
|
||||
/** Money still owed on a live invoice — shown in red. A cancelled invoice owes nothing. */
|
||||
function hasRemaining(row: SalesInvoiceSummary): boolean {
|
||||
return row.status !== "Cancelled" && row.totals.balanceAmount > 0
|
||||
}
|
||||
|
||||
export default function SalesInvoicesPage() {
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const stats = useDashboardStats()
|
||||
|
||||
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [dateRange, setDateRange] = useState<DateRange>("All")
|
||||
const [customRange, setCustomRange] = useState<CustomRange | undefined>(undefined)
|
||||
// Strings, so the effects below re-run only when the actual bounds change.
|
||||
const { from: dateFrom, to: dateTo } = dateRangeBounds(dateRange, customRange)
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [page, setPage] = useState(1)
|
||||
@@ -135,25 +125,19 @@ export default function SalesInvoicesPage() {
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
// The invoice open in the pop-up view (row click, invoice no. or the print icon). Read through
|
||||
// `rows` so its buttons follow payments/confirms; viewRefresh reloads the sheet after those.
|
||||
const [viewInvoice, setViewInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||
const [viewRefresh, setViewRefresh] = useState(0)
|
||||
const viewRow = viewInvoice ? (rows?.find((r) => r.salesInvoiceId === viewInvoice.salesInvoiceId) ?? viewInvoice) : null
|
||||
// This screen lists drafts only; the row, its invoice no. and the invoice icon all open the
|
||||
// invoice page, where a draft is editable.
|
||||
const router = useRouter()
|
||||
|
||||
// Multi-invoice payment: ticked rows (kept across pages), all for one customer. The generic cash
|
||||
// customer's invoices can't be ticked — they're paid one at a time.
|
||||
const [selected, setSelected] = useState<Map<number, SalesInvoiceSummary>>(new Map())
|
||||
const [bulkPayOpen, setBulkPayOpen] = useState(false)
|
||||
const [cashCustomerId, setCashCustomerId] = useState<number | null>(null)
|
||||
function openDraft(row: SalesInvoiceSummary) {
|
||||
router.push(`/dashboard/sales/invoices/${row.salesInvoiceId}`)
|
||||
}
|
||||
|
||||
// Ticked drafts for "Approve selected" — cleared whenever the list's filters change.
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [bulkApproving, setBulkApproving] = useState(false)
|
||||
|
||||
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
||||
// While invoices are ticked the list is narrowed to their customer (overriding the Customer
|
||||
// filter); clearing the selection falls back to whatever filter was chosen.
|
||||
const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null
|
||||
const listCustomerId = selectedCustomerId ?? customerId
|
||||
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
||||
const [customers, setCustomers] = useState<Customer[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
@@ -166,13 +150,12 @@ export default function SalesInvoicesPage() {
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
setPageSize(rowsPerPage)
|
||||
}, [query, status, dateRange, activeBranchId, listCustomerId, warehouseId, rowsPerPage])
|
||||
setSelected(new Set())
|
||||
}, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage])
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
|
||||
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||
// A failed read only loosens the UI guard — the server rejects cash-customer bulk payments anyway.
|
||||
salesConfigApi.get().then((r) => setCashCustomerId(r.data.cashCustomerId)).catch(() => setCashCustomerId(null))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -181,11 +164,13 @@ export default function SalesInvoicesPage() {
|
||||
.listInvoices({
|
||||
page,
|
||||
pageSize,
|
||||
status: status === "All" ? undefined : status,
|
||||
// Drafts only — approved invoices are worked (and paid) from the Day End screen.
|
||||
status: "Draft",
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
customerId: listCustomerId,
|
||||
customerId,
|
||||
warehouseId,
|
||||
...dateRangeBounds(dateRange),
|
||||
from: dateFrom,
|
||||
to: dateTo,
|
||||
})
|
||||
.then((res) => {
|
||||
setRows(res.items)
|
||||
@@ -193,7 +178,7 @@ export default function SalesInvoicesPage() {
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, listCustomerId, warehouseId])
|
||||
}, [page, pageSize, dateFrom, dateTo, activeBranchId, branchingEnabled, customerId, warehouseId])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
@@ -204,9 +189,14 @@ export default function SalesInvoicesPage() {
|
||||
setConfirmingId(row.salesInvoiceId)
|
||||
try {
|
||||
const posted = await salesApi.postInvoice(row.salesInvoiceId)
|
||||
setRows((prev) => prev?.map((r) => (r.salesInvoiceId === row.salesInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
|
||||
// No longer a draft, so it leaves this list — it's now on Day End.
|
||||
setRows((prev) => prev?.filter((r) => r.salesInvoiceId !== row.salesInvoiceId) ?? prev)
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(row.salesInvoiceId)
|
||||
return next
|
||||
})
|
||||
toast.success("Invoice confirmed", posted.invoiceNo)
|
||||
setViewRefresh((n) => n + 1)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
} finally {
|
||||
@@ -214,81 +204,38 @@ export default function SalesInvoicesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaid(payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) {
|
||||
setViewRefresh((n) => n + 1)
|
||||
setRows(
|
||||
(prev) =>
|
||||
prev?.map((r) =>
|
||||
r.salesInvoiceId === payment.salesInvoiceId
|
||||
? {
|
||||
...r,
|
||||
status: newStatus,
|
||||
totals: { ...r.totals, paidAmount: r.totals.paidAmount + payment.amount, balanceAmount: r.totals.balanceAmount - payment.amount },
|
||||
}
|
||||
: r
|
||||
) ?? prev
|
||||
)
|
||||
// Keep a ticked invoice's balance current, and untick it once nothing is left to pay.
|
||||
// No bulk endpoint — each draft is approved in turn (each posts its own stock + GL), so one that
|
||||
// fails (e.g. short stock) stays ticked in the list while the rest go through.
|
||||
async function handleBulkApprove() {
|
||||
const ids = [...selected]
|
||||
setBulkApproving(true)
|
||||
const approved: number[] = []
|
||||
const failed: string[] = []
|
||||
for (const id of ids) {
|
||||
const row = rows?.find((r) => r.salesInvoiceId === id)
|
||||
try {
|
||||
await salesApi.postInvoice(id)
|
||||
approved.push(id)
|
||||
} catch (err) {
|
||||
failed.push(`${row?.invoiceNo ?? id}: ${errorMessage(err)}`)
|
||||
}
|
||||
}
|
||||
setRows((prev) => prev?.filter((r) => !approved.includes(r.salesInvoiceId)) ?? prev)
|
||||
setSelected((prev) => new Set([...prev].filter((id) => !approved.includes(id))))
|
||||
setBulkApproving(false)
|
||||
if (approved.length > 0) toast.success("Invoices approved", `${approved.length} invoice(s) approved.`)
|
||||
if (failed.length > 0) toast.error(`${failed.length} invoice(s) could not be approved`, failed.join("\n"))
|
||||
}
|
||||
|
||||
function toggleSelected(id: number) {
|
||||
setSelected((prev) => {
|
||||
const sel = prev.get(payment.salesInvoiceId)
|
||||
if (!sel) return prev
|
||||
const next = new Map(prev)
|
||||
const balanceAmount = sel.totals.balanceAmount - payment.amount
|
||||
if (balanceAmount <= 0) next.delete(payment.salesInvoiceId)
|
||||
else next.set(payment.salesInvoiceId, { ...sel, totals: { ...sel.totals, paidAmount: sel.totals.paidAmount + payment.amount, balanceAmount } })
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function handleBulkConfirmed(salesInvoiceId: number) {
|
||||
const asPosted = (r: SalesInvoiceSummary) => (r.salesInvoiceId === salesInvoiceId ? { ...r, status: "Posted" as const } : r)
|
||||
setRows((prev) => prev?.map(asPosted) ?? prev)
|
||||
setSelected((prev) => {
|
||||
const sel = prev.get(salesInvoiceId)
|
||||
return sel ? new Map(prev).set(salesInvoiceId, asPosted(sel)) : prev
|
||||
})
|
||||
}
|
||||
|
||||
function handleBulkPaid(payments: SalesInvoicePayment[]) {
|
||||
payments.forEach((p) => handlePaid(p, "Posted"))
|
||||
// Partly-paid ones would stay ticked via handlePaid; a finished payment clears the whole selection.
|
||||
setSelected(new Map())
|
||||
}
|
||||
|
||||
const selectedBalance = [...selected.values()].reduce((sum, r) => sum + r.totals.balanceAmount, 0)
|
||||
|
||||
/** Null when the row can be ticked, else why not (shown as the checkbox's tooltip). */
|
||||
function notSelectableReason(row: SalesInvoiceSummary): string | null {
|
||||
if (selected.has(row.salesInvoiceId)) return null
|
||||
if (row.status === "Cancelled") return "Cancelled invoices can't be paid"
|
||||
if (row.totals.balanceAmount <= 0) return "Already fully paid"
|
||||
if (cashCustomerId !== null && row.customerId === cashCustomerId) return "Cash customer invoices are paid one at a time"
|
||||
if (selectedCustomerId !== null && row.customerId !== selectedCustomerId) return "Select invoices of one customer only"
|
||||
return null
|
||||
}
|
||||
|
||||
function toggleSelected(row: SalesInvoiceSummary) {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev)
|
||||
if (next.has(row.salesInvoiceId)) next.delete(row.salesInvoiceId)
|
||||
else next.set(row.salesInvoiceId, row)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function renderSelectBox(row: SalesInvoiceSummary) {
|
||||
const reason = notSelectableReason(row)
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selected.has(row.salesInvoiceId)}
|
||||
disabled={reason !== null}
|
||||
title={reason ?? "Select to pay together"}
|
||||
aria-label={`Select ${row.invoiceNo}`}
|
||||
onCheckedChange={() => toggleSelected(row)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Desktop table sorting (Items-table style) — over the loaded page, client-side; null keeps the
|
||||
// server's order.
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||||
@@ -316,9 +263,9 @@ export default function SalesInvoicesPage() {
|
||||
case "customer": return r.customerSnapshotName
|
||||
case "date": return new Date(r.createdAt).getTime()
|
||||
case "due": return new Date(r.invoiceDate).getTime()
|
||||
case "gross": return r.totals.subtotal
|
||||
case "discount": return r.totals.discountTotal
|
||||
case "net": return r.totals.grandTotal
|
||||
case "paid": return r.totals.paidAmount
|
||||
case "remaining": return r.status === "Cancelled" ? 0 : r.totals.balanceAmount
|
||||
}
|
||||
}
|
||||
return [...visibleRows].sort((a, b) => {
|
||||
@@ -328,20 +275,28 @@ export default function SalesInvoicesPage() {
|
||||
})
|
||||
}, [visibleRows, sortKey, sortOrder])
|
||||
|
||||
// Header "select all": ticks every tickable row on screen, but only when they're all one customer
|
||||
// (e.g. once a selection has narrowed the list) — invoices of different customers never mix.
|
||||
const tickable = sortedRows.filter((r) => notSelectableReason(r) === null)
|
||||
const canSelectAll = tickable.length > 0 && new Set(tickable.map((r) => r.customerId)).size === 1
|
||||
const allTicked = tickable.length > 0 && tickable.every((r) => selected.has(r.salesInvoiceId))
|
||||
// Header "select all" ticks every draft on screen (the loaded page, after search).
|
||||
const allTicked = sortedRows.length > 0 && sortedRows.every((r) => selected.has(r.salesInvoiceId))
|
||||
function toggleAll(checked: boolean) {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev)
|
||||
tickable.forEach((r) => (checked ? next.set(r.salesInvoiceId, r) : next.delete(r.salesInvoiceId)))
|
||||
const next = new Set(prev)
|
||||
sortedRows.forEach((r) => (checked ? next.add(r.salesInvoiceId) : next.delete(r.salesInvoiceId)))
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
||||
function renderSelectBox(row: SalesInvoiceSummary) {
|
||||
return (
|
||||
<Checkbox
|
||||
checked={selected.has(row.salesInvoiceId)}
|
||||
disabled={bulkApproving}
|
||||
aria-label={`Select ${row.invoiceNo}`}
|
||||
onCheckedChange={() => toggleSelected(row.salesInvoiceId)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const hasFilters = dateFrom !== undefined || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
|
||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||
@@ -361,28 +316,26 @@ export default function SalesInvoicesPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Totals are over the invoices currently listed (filters + loaded page). Passed as
|
||||
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
|
||||
{/* Same order as the table: how many drafts, then Gross → Discount → Net. Draft count is every
|
||||
matching draft; the amounts are over the invoices currently listed (filters + loaded page),
|
||||
passed as strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stats ? (
|
||||
<StatCard label="Overdue Invoices" value={stats.overdueSalesInvoices} icon={AlertTriangle} />
|
||||
) : (
|
||||
<Skeleton className="h-22.5 rounded-xl" />
|
||||
)}
|
||||
{rows !== null ? (
|
||||
<>
|
||||
<StatCard label="Draft invoices" value={pagination?.totalItems ?? rows.length} icon={FileText} />
|
||||
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
|
||||
<StatCard label="Discount total" value={formatAmount(discountTotal)} icon={Tag} />
|
||||
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<FilterBar
|
||||
className="border-b px-4 py-4"
|
||||
searchFirst
|
||||
search={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchPlaceholder="Filter by customer or invoice number"
|
||||
@@ -393,9 +346,8 @@ export default function SalesInvoicesPage() {
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-filter-customer">Customer</FieldLabel>
|
||||
<Select
|
||||
value={listCustomerId ? String(listCustomerId) : "All"}
|
||||
value={customerId ? String(customerId) : "All"}
|
||||
onValueChange={(v) => setCustomerId(v && v !== "All" ? Number(v) : undefined)}
|
||||
disabled={selectedCustomerId !== null}
|
||||
>
|
||||
<SelectTrigger id="inv-filter-customer"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -418,22 +370,6 @@ export default function SalesInvoicesPage() {
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select<StatusFilter>
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v ?? "All")}
|
||||
items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
|
||||
<ListFilter className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tabs.map((t) => (
|
||||
<SelectItem key={t.value} value={t.value}>{t.value === "All" ? "All statuses" : t.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select<DateRange>
|
||||
value={dateRange}
|
||||
onValueChange={(v) => setDateRange(v ?? "All")}
|
||||
@@ -449,26 +385,25 @@ export default function SalesInvoicesPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{dateRange === "Custom" && (
|
||||
<DateRangePicker value={customRange} onChange={setCustomRange} placeholder="Pick from – to dates" className="sm:w-72" />
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-col gap-2 border-b bg-primary/5 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold">{selected.size}</span> invoice{selected.size === 1 ? "" : "s"} selected for{" "}
|
||||
<span className="font-semibold">{selected.values().next().value!.customerSnapshotName}</span> — balance{" "}
|
||||
<span className="font-semibold tabular-nums">{formatAmount(selectedBalance)}</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
Showing only this customer's invoices — clear the selection to see all.
|
||||
</span>
|
||||
<span className="font-semibold">{selected.size}</span> draft{selected.size === 1 ? "" : "s"} selected
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setSelected(new Map())}>
|
||||
<Button type="button" variant="outline" size="sm" disabled={bulkApproving} onClick={() => setSelected(new Set())}>
|
||||
Clear
|
||||
</Button>
|
||||
<Button type="button" size="sm" onClick={() => setBulkPayOpen(true)}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay selected
|
||||
<Button type="button" size="sm" disabled={bulkApproving} onClick={handleBulkApprove}>
|
||||
{bulkApproving ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
{bulkApproving ? "Approving…" : "Approve selected"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -495,6 +430,15 @@ export default function SalesInvoicesPage() {
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
|
||||
<label className="flex items-center gap-3 px-1 text-sm text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={allTicked}
|
||||
disabled={bulkApproving}
|
||||
onCheckedChange={(c) => toggleAll(c === true)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
Select all
|
||||
</label>
|
||||
{visibleRows.map((row) => (
|
||||
<div
|
||||
key={row.salesInvoiceId}
|
||||
@@ -502,10 +446,10 @@ export default function SalesInvoicesPage() {
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
setViewInvoice(row)
|
||||
openDraft(row)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") setViewInvoice(row)
|
||||
if (e.key === "Enter") openDraft(row)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
@@ -515,8 +459,8 @@ export default function SalesInvoicesPage() {
|
||||
{renderSelectBox(row)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
|
||||
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
|
||||
<p className="truncate font-semibold text-foreground">{row.invoiceNo}</p>
|
||||
<p className="text-sm text-muted-foreground">{row.customerSnapshotName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
@@ -538,22 +482,17 @@ export default function SalesInvoicesPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Gross / Discount</p>
|
||||
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)} / -{row.totals.discountTotal.toFixed(2)}</p>
|
||||
<p className="text-muted-foreground">Gross</p>
|
||||
<p className="font-medium text-foreground">{row.totals.subtotal.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Discount</p>
|
||||
<p className="font-medium text-foreground">{row.totals.discountTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Net</p>
|
||||
<p className="font-semibold text-foreground">{row.totals.grandTotal.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Paid / Remaining</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{row.totals.paidAmount.toFixed(2)} /{" "}
|
||||
<span className={cn(hasRemaining(row) && "font-semibold text-destructive")}>
|
||||
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
||||
@@ -561,11 +500,11 @@ export default function SalesInvoicesPage() {
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setViewInvoice(row)}
|
||||
aria-label={`Print ${row.invoiceNo}`}
|
||||
title="Print"
|
||||
onClick={() => openDraft(row)}
|
||||
aria-label={`Open ${row.invoiceNo} to edit`}
|
||||
title="Open draft to edit"
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
@@ -580,21 +519,6 @@ export default function SalesInvoicesPage() {
|
||||
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(row)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${row.invoiceNo}`}
|
||||
title="Pay"
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -613,20 +537,20 @@ export default function SalesInvoicesPage() {
|
||||
<TableRow>
|
||||
<TableHead className="h-12 w-10 px-3">
|
||||
<Checkbox
|
||||
checked={canSelectAll && allTicked}
|
||||
disabled={!canSelectAll}
|
||||
checked={allTicked}
|
||||
disabled={bulkApproving}
|
||||
onCheckedChange={(c) => toggleAll(c === true)}
|
||||
aria-label="Select all"
|
||||
title={canSelectAll ? "Select all" : "Select an invoice first — all must be one customer"}
|
||||
title="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Invoice" active={sortKey === "invoiceNo"} order={sortOrder} onClick={() => toggleSort("invoiceNo")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Customer" active={sortKey === "customer"} order={sortOrder} onClick={() => toggleSort("customer")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Date" active={sortKey === "date"} order={sortOrder} onClick={() => toggleSort("date")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Due" active={sortKey === "due"} order={sortOrder} onClick={() => toggleSort("due")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Gross" active={sortKey === "gross"} order={sortOrder} onClick={() => toggleSort("gross")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Discount" active={sortKey === "discount"} order={sortOrder} onClick={() => toggleSort("discount")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Net" active={sortKey === "net"} order={sortOrder} onClick={() => toggleSort("net")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Paid" active={sortKey === "paid"} order={sortOrder} onClick={() => toggleSort("paid")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Remaining" active={sortKey === "remaining"} order={sortOrder} onClick={() => toggleSort("remaining")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -638,7 +562,7 @@ export default function SalesInvoicesPage() {
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
setViewInvoice(row)
|
||||
openDraft(row)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
@@ -654,12 +578,6 @@ export default function SalesInvoicesPage() {
|
||||
href={`/dashboard/sales/invoices/${row.salesInvoiceId}`}
|
||||
className="hover:underline"
|
||||
data-no-row-nav
|
||||
onClick={(e) => {
|
||||
// Plain click opens the pop-up; Ctrl/⌘-click still opens the full page in a new tab.
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
setViewInvoice(row)
|
||||
}}
|
||||
>
|
||||
{row.invoiceNo}
|
||||
</Link>
|
||||
@@ -672,17 +590,9 @@ export default function SalesInvoicesPage() {
|
||||
<TableCell className={cn("px-3 py-3.5", isOverdue(row) && "font-semibold text-destructive")}>
|
||||
{new Date(row.invoiceDate).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
<p className="font-semibold">{row.totals.grandTotal.toFixed(2)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gross {row.totals.subtotal.toFixed(2)}
|
||||
{row.totals.discountTotal > 0 && ` · Disc ${row.totals.discountTotal.toFixed(2)}`}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.paidAmount.toFixed(2)}</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5 text-right tabular-nums", hasRemaining(row) && "font-semibold text-destructive")}>
|
||||
{row.status === "Cancelled" ? "—" : row.totals.balanceAmount.toFixed(2)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{row.totals.discountTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<SalesInvoiceStatusBadge status={row.status} />
|
||||
@@ -697,11 +607,11 @@ export default function SalesInvoicesPage() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setViewInvoice(row)}
|
||||
aria-label={`Print ${row.invoiceNo}`}
|
||||
title="Print"
|
||||
onClick={() => openDraft(row)}
|
||||
aria-label={`Open ${row.invoiceNo} to edit`}
|
||||
title="Open draft to edit"
|
||||
>
|
||||
<Printer className="size-4" />
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
@@ -716,21 +626,6 @@ export default function SalesInvoicesPage() {
|
||||
{confirmingId === row.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(row)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${row.invoiceNo}`}
|
||||
title="Pay"
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -752,56 +647,6 @@ export default function SalesInvoicesPage() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||
<SalesInvoiceBulkPaymentDialog
|
||||
invoices={[...selected.values()]}
|
||||
open={bulkPayOpen}
|
||||
onOpenChange={setBulkPayOpen}
|
||||
onConfirmed={handleBulkConfirmed}
|
||||
onPaid={handleBulkPaid}
|
||||
/>
|
||||
<SalesInvoicePrintDialog
|
||||
invoiceId={viewRow?.salesInvoiceId ?? null}
|
||||
invoiceNo={viewRow?.invoiceNo}
|
||||
open={viewRow !== null}
|
||||
onOpenChange={(open) => { if (!open) setViewInvoice(null) }}
|
||||
refreshKey={viewRefresh}
|
||||
actions={
|
||||
viewRow && (
|
||||
<>
|
||||
{viewRow.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={confirmingId === viewRow.salesInvoiceId}
|
||||
onClick={() => handleConfirm(viewRow)}
|
||||
aria-label={`Confirm ${viewRow.invoiceNo}`}
|
||||
title="Confirm"
|
||||
>
|
||||
{confirmingId === viewRow.salesInvoiceId ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{viewRow.status !== "Cancelled" && viewRow.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(viewRow)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
aria-label={`Pay ${viewRow.invoiceNo}`}
|
||||
title="Pay"
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface ListCustomersParams {
|
||||
/** The system cash customer (walk-in / one-time buyers) is left out by default — pass true only
|
||||
* where the list is used to resolve names for existing documents. */
|
||||
includeCashCustomer?: boolean
|
||||
/** Branch-wise list (same as Sales / GRN): only customers created under this branch, plus the
|
||||
* shared cash customer. Omit for every branch. */
|
||||
branchId?: number
|
||||
}
|
||||
|
||||
export const customersApi = {
|
||||
|
||||
@@ -35,6 +35,12 @@ export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
||||
/** yyyy-MM-dd, inclusive; filters on InvoiceDate. */
|
||||
from?: string
|
||||
to?: string
|
||||
/** UserId who created the invoice — Day End's cashier match. */
|
||||
createdBy?: number
|
||||
/** true = fully paid, false = balance still due. */
|
||||
paid?: boolean
|
||||
/** Matches invoice number or customer name. */
|
||||
q?: string
|
||||
}
|
||||
|
||||
export interface ListSalesSlipsParams extends SalesListFilterParams {
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface SalesInvoiceSummary {
|
||||
totals: SalesInvoiceTotals
|
||||
createdAt: string
|
||||
glJournalNo: string | null
|
||||
/** UserId who created it — Day End's cashier. */
|
||||
createdBy: number
|
||||
}
|
||||
|
||||
export interface SalesInvoicePostingIssue {
|
||||
|
||||
Reference in New Issue
Block a user