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