fixing recipts in sales
This commit is contained in:
@@ -29,8 +29,10 @@ public sealed class SalesInvoicesController : ApiControllerBase
|
|||||||
[FromQuery] int? customerId,
|
[FromQuery] int? customerId,
|
||||||
[FromQuery] int? warehouseId,
|
[FromQuery] int? warehouseId,
|
||||||
[FromQuery] int? branchId,
|
[FromQuery] int? branchId,
|
||||||
|
[FromQuery] DateOnly? from,
|
||||||
|
[FromQuery] DateOnly? to,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, ct));
|
=> Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, ct));
|
||||||
|
|
||||||
[HttpGet("{salesInvoiceId:int}")]
|
[HttpGet("{salesInvoiceId:int}")]
|
||||||
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -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, CancellationToken ct = default);
|
Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default);
|
||||||
Task<ETagged<SalesInvoiceDto>?> GetAsync(int salesInvoiceId, CancellationToken ct = default);
|
Task<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, CancellationToken ct = default)
|
public async Task<PagedResponse<SalesInvoiceSummaryDto>> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
IQueryable<SalesInvoice> q = _invoices.Query().AsNoTracking().Include(x => x.Lines);
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -64,6 +64,9 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
|||||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||||
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
|
// Warehouses are shared across branches — the invoice carries its own owning BranchId.
|
||||||
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
|
if (branchId is not null) q = q.Where(x => x.BranchId == branchId);
|
||||||
|
// Whole days, inclusive of `to` — same InvoiceDate boundaries as SalesReportService.
|
||||||
|
if (from is not null) q = q.Where(x => x.InvoiceDate >= from.Value.ToDateTime(TimeOnly.MinValue));
|
||||||
|
if (to is not null) q = q.Where(x => x.InvoiceDate < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { toast } from "@/components/ui/toast"
|
|||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
|
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
|
||||||
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||||
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
||||||
@@ -66,6 +67,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
const invoiceId = Number(resolvedParams.id)
|
const invoiceId = Number(resolvedParams.id)
|
||||||
|
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
||||||
|
const [printOpen, setPrintOpen] = useState(false)
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
const [customers, setCustomers] = useState<Customer[]>([])
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
const [items, setItems] = useState<ItemListItem[]>([])
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
const [uoms, setUoms] = useState<Uom[]>([])
|
||||||
@@ -302,17 +304,23 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<button
|
||||||
href={`/print/sales/invoices/${invoice.salesInvoiceId}`}
|
type="button"
|
||||||
target="_blank"
|
onClick={() => setPrintOpen(true)}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
|
className={cn("inline-flex h-10 items-center gap-2 rounded-full border border-black bg-white px-4 text-sm font-medium shadow-sm hover:bg-muted")}
|
||||||
>
|
>
|
||||||
<Printer className="size-4" />
|
<Printer className="size-4" />
|
||||||
Print
|
Print
|
||||||
</Link>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<SalesInvoicePrintDialog
|
||||||
|
invoiceId={invoice.salesInvoiceId}
|
||||||
|
invoiceNo={invoice.invoiceNo}
|
||||||
|
open={printOpen}
|
||||||
|
onOpenChange={setPrintOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null}
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
<section className="bg-white print:bg-white">
|
<section className="bg-white print:bg-white">
|
||||||
|
|||||||
@@ -1,197 +1,27 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Printer } from "lucide-react"
|
import { useParams } from "next/navigation"
|
||||||
|
import { ArrowLeft } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
|
||||||
import { customersApi } from "@/lib/api/customers"
|
|
||||||
import { itemsApi } from "@/lib/api/items"
|
|
||||||
import { uomsApi } from "@/lib/api/uoms"
|
|
||||||
import { baseUomLabel } from "@/lib/uom-label"
|
|
||||||
import { warehousesApi } from "@/lib/api/warehouses"
|
|
||||||
import { errorMessage } from "@/lib/error-map"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton"
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Customer } from "@/types/customers"
|
import { buttonVariants } from "@/components/ui/button"
|
||||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
import { SalesInvoicePrintPreview } from "@/components/sales/SalesInvoicePrint"
|
||||||
import { SalesInvoice } from "@/types/sales"
|
|
||||||
|
|
||||||
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
|
export default function SalesInvoicePrintPage() {
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
const invoiceId = Number(params.id)
|
const invoiceId = Number(params.id)
|
||||||
const [invoice, setInvoice] = useState<SalesInvoice | null>(null)
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([])
|
|
||||||
const [items, setItems] = useState<ItemListItem[]>([])
|
|
||||||
const [uoms, setUoms] = useState<Uom[]>([])
|
|
||||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!Number.isFinite(invoiceId)) {
|
|
||||||
setError(`Invalid invoice id '${params.id}'.`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
Promise.all([
|
|
||||||
customersApi.list({ pageSize: 200 }),
|
|
||||||
itemsApi.list({ pageSize: 200 }),
|
|
||||||
uomsApi.list({ pageSize: 200 }),
|
|
||||||
warehousesApi.list({ pageSize: 200 }),
|
|
||||||
salesApi.getInvoice(invoiceId),
|
|
||||||
])
|
|
||||||
.then(([cust, itemRes, uomRes, whRes, doc]) => {
|
|
||||||
setCustomers(cust.items)
|
|
||||||
setItems(itemRes.items)
|
|
||||||
setUoms(uomRes.items)
|
|
||||||
setWarehouses(whRes.items)
|
|
||||||
setInvoice(doc.data)
|
|
||||||
})
|
|
||||||
.catch((err) => setError(errorMessage(err)))
|
|
||||||
}, [params.id, invoiceId])
|
|
||||||
|
|
||||||
const subtotal = useMemo(() => invoice?.totals.subtotal ?? 0, [invoice])
|
|
||||||
const freeQtyTotal = invoice?.totals.freeQtyTotal ?? 0
|
|
||||||
|
|
||||||
if (error && !invoice) {
|
|
||||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!invoice) {
|
|
||||||
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice print data is loading or unavailable."}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
const customer = customers.find((c) => c.customerId === invoice.customerId)
|
|
||||||
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 print:block print:gap-0">
|
<div className="flex flex-col gap-6">
|
||||||
<div className="flex items-center justify-between gap-3 print:hidden">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<Link href={`/dashboard/sales/invoices/${params.id}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))} aria-label="Back to invoice">
|
||||||
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
<ArrowLeft className="size-4" />
|
||||||
<ArrowLeft className="size-4" />
|
</Link>
|
||||||
</Link>
|
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
|
|
||||||
<p className="text-base text-muted-foreground">{invoice.invoiceNo}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => window.print()}>
|
|
||||||
<Printer className="size-4" />
|
|
||||||
Print
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="invoice-sheet rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
<SalesInvoicePrintPreview invoiceId={invoiceId} className="overflow-hidden rounded-2xl border shadow-sm" />
|
||||||
<div className="invoice-header mb-6 grid gap-4 border-b pb-5 md:grid-cols-[1.4fr_1fr]">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoice</div>
|
|
||||||
<h1 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h1>
|
|
||||||
<div className="mt-2 text-sm text-muted-foreground">Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 text-sm md:justify-items-end">
|
|
||||||
<div className="font-semibold text-foreground">ERP Core Trading</div>
|
|
||||||
<div className="text-muted-foreground">Company details are not configured for this print view.</div>
|
|
||||||
<div className="text-muted-foreground">Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}</div>
|
|
||||||
<div className="text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
|
||||||
<div className="text-muted-foreground">Free qty total: {freeQtyTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
|
|
||||||
<div className="mt-2 text-lg font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {invoice.customerId}</div>
|
|
||||||
{invoice.customerSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.customerSnapshotTaxNo}</div> : null}
|
|
||||||
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
|
|
||||||
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{invoice.totals.subtotal.toFixed(2)}</div>
|
|
||||||
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{invoice.totals.discountTotal.toFixed(2)}</div>
|
|
||||||
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{freeQtyTotal.toFixed(2)}</div>
|
|
||||||
<div className="text-muted-foreground">Tax</div><div className="text-right font-medium">{invoice.totals.taxTotal.toFixed(2)}</div>
|
|
||||||
<div className="text-muted-foreground">Net payable</div><div className="text-right font-semibold">{invoice.totals.netPayable.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 overflow-hidden rounded-2xl border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-[34%]">Item</TableHead>
|
|
||||||
<TableHead>UOM</TableHead>
|
|
||||||
<TableHead className="text-right">Qty</TableHead>
|
|
||||||
<TableHead className="text-right">Free</TableHead>
|
|
||||||
<TableHead className="text-right">Unit price</TableHead>
|
|
||||||
<TableHead className="text-right">Discount</TableHead>
|
|
||||||
<TableHead className="text-right">Tax</TableHead>
|
|
||||||
<TableHead className="text-right">Line total</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{invoice.lines.map((line) => (
|
|
||||||
<TableRow key={line.salesInvoiceLineId}>
|
|
||||||
<TableCell>
|
|
||||||
<div className="font-medium text-foreground">{line.description}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.qty.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.discountAmount.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right">{line.taxAmount.toFixed(2)}</TableCell>
|
|
||||||
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{invoice.lines.some((line) => line.freeQty > 0) ? (
|
|
||||||
<div className="mt-6 rounded-2xl border border-dashed p-4 print:break-inside-avoid">
|
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Free issue summary</div>
|
|
||||||
<div className="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{invoice.lines.filter((line) => line.freeQty > 0).map((line) => (
|
|
||||||
<div key={line.salesInvoiceLineId} className="rounded-xl border bg-emerald-500/5 p-3">
|
|
||||||
<div className="font-medium text-foreground">{line.description}</div>
|
|
||||||
<div className="mt-1 text-sm text-muted-foreground">Invoice qty: {line.qty.toFixed(2)}</div>
|
|
||||||
<div className="mt-1 text-sm text-foreground">Free qty: {line.freeQty.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-6 grid gap-4 lg:grid-cols-[1fr_360px]">
|
|
||||||
<div className="rounded-2xl border bg-muted/20 p-4">
|
|
||||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">Standard invoice print view.</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border p-4">
|
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
|
|
||||||
<div className="mt-3 space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Subtotal</span><span>{invoice.totals.subtotal.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Discount total</span><span>{invoice.totals.discountTotal.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Free qty total</span><span>{invoice.totals.freeQtyTotal.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between"><span className="text-muted-foreground">Tax total</span><span>{invoice.totals.taxTotal.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between border-t pt-2 text-base font-semibold"><span>Net payable</span><span>{invoice.totals.netPayable.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between text-muted-foreground"><span>Paid</span><span>{invoice.totals.paidAmount.toFixed(2)}</span></div>
|
|
||||||
<div className="flex justify-between text-muted-foreground"><span>Balance</span><span>{invoice.totals.balanceAmount.toFixed(2)}</span></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
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 { useRouter } from "next/navigation"
|
||||||
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, FileText, Plus, Printer } from "lucide-react"
|
import { AlertTriangle, CalendarDays, CheckCircle2, CircleDollarSign, FileText, ListFilter, Plus, Printer, Receipt, Tag } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -15,12 +15,13 @@ import { Button, buttonVariants } from "@/components/ui/button"
|
|||||||
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"
|
||||||
import { KpiRow } from "@/components/ui/kpi-row"
|
import { StatCard } from "@/components/ui/stat-card"
|
||||||
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"
|
||||||
import { toast } from "@/components/ui/toast"
|
import { toast } from "@/components/ui/toast"
|
||||||
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
|
||||||
|
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 { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
|
||||||
@@ -30,10 +31,37 @@ import { cn } from "@/lib/utils"
|
|||||||
|
|
||||||
type StatusFilter = SalesInvoiceStatus | "All"
|
type StatusFilter = SalesInvoiceStatus | "All"
|
||||||
|
|
||||||
const PAGE_SIZE = 25
|
function formatAmount(n: number) {
|
||||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
}
|
||||||
const MOBILE_PAGE_SIZE_STEP = 25
|
|
||||||
|
type DateRange = "All" | "Today" | "7d" | "30d"
|
||||||
|
|
||||||
|
const dateRanges: { value: DateRange; label: string; days: number | null }[] = [
|
||||||
|
{ value: "All", label: "All time", days: null },
|
||||||
|
{ value: "Today", label: "Today", days: 1 },
|
||||||
|
{ value: "7d", label: "7 days", days: 7 },
|
||||||
|
{ value: "30d", label: "30 days", days: 30 },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
|
||||||
|
function toIsoDate(d: Date) {
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inclusive range ending today: "7 days" is today plus the 6 days before it. */
|
||||||
|
function dateRangeBounds(range: DateRange): { from?: string; to?: string } {
|
||||||
|
const days = dateRanges.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: toIsoDate(from), to: toIsoDate(today) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||||
|
// Mobile has no page-size selector — tapping "Show more" just grows the page size by the
|
||||||
|
// chosen rows-per-page and re-fetches from page 1, so loaded cards stay put and more append.
|
||||||
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
const tabs: { value: SalesInvoiceStatus | "All"; label: string }[] = [
|
||||||
{ value: "All", label: "All" },
|
{ value: "All", label: "All" },
|
||||||
{ value: "Draft", label: "Draft" },
|
{ value: "Draft", label: "Draft" },
|
||||||
@@ -50,14 +78,18 @@ export default function SalesInvoicesPage() {
|
|||||||
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 [status, setStatus] = useState<StatusFilter>("All")
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>("All")
|
||||||
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)
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
// rowsPerPage is the user's choice; pageSize is what's fetched (grows on mobile "Show more").
|
||||||
|
const [rowsPerPage, setRowsPerPage] = 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)
|
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||||
|
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
|
||||||
|
|
||||||
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
|
||||||
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
|
||||||
@@ -71,8 +103,8 @@ export default function SalesInvoicesPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setPageSize(PAGE_SIZE)
|
setPageSize(rowsPerPage)
|
||||||
}, [query, status, activeBranchId, customerId, warehouseId])
|
}, [query, status, dateRange, 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([]))
|
||||||
@@ -89,6 +121,7 @@ export default function SalesInvoicesPage() {
|
|||||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||||
customerId,
|
customerId,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
|
...dateRangeBounds(dateRange),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
setRows(res.items)
|
setRows(res.items)
|
||||||
@@ -96,11 +129,11 @@ export default function SalesInvoicesPage() {
|
|||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
.finally(() => setShowMoreLoading(false))
|
.finally(() => setShowMoreLoading(false))
|
||||||
}, [page, pageSize, status, activeBranchId, branchingEnabled, customerId, warehouseId])
|
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
|
||||||
|
|
||||||
function handleShowMore() {
|
function handleShowMore() {
|
||||||
setShowMoreLoading(true)
|
setShowMoreLoading(true)
|
||||||
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
|
setPageSize((s) => s + rowsPerPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirm(row: SalesInvoiceSummary) {
|
async function handleConfirm(row: SalesInvoiceSummary) {
|
||||||
@@ -138,11 +171,14 @@ export default function SalesInvoicesPage() {
|
|||||||
) ?? [],
|
) ?? [],
|
||||||
[rows, query]
|
[rows, query]
|
||||||
)
|
)
|
||||||
const hasFilters = status !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
const hasFilters = status !== "All" || dateRange !== "All" || query.length > 0 || customerId !== undefined || warehouseId !== undefined
|
||||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
const 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)
|
||||||
const printHref = `/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
const printRange = dateRangeBounds(dateRange)
|
||||||
|
const printHref =
|
||||||
|
`/print/sales/invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}` +
|
||||||
|
(printRange.from && printRange.to ? `&from=${printRange.from}&to=${printRange.to}` : "")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -168,7 +204,24 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KpiRow tiles={stats ? [{ label: "Overdue Invoices", value: stats.overdueSalesInvoices, icon: AlertTriangle }] : null} />
|
{/* Totals are over the invoices currently listed (filters + loaded page). Passed as
|
||||||
|
strings so StatCard shows full 2dp amounts rather than its compact "1.2K" form. */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{stats ? (
|
||||||
|
<StatCard label="Overdue Invoices" value={stats.overdueSalesInvoices} icon={AlertTriangle} />
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-22.5 rounded-xl" />
|
||||||
|
)}
|
||||||
|
{rows !== null ? (
|
||||||
|
<>
|
||||||
|
<StatCard label="Gross total" value={formatAmount(grossTotal)} icon={Receipt} />
|
||||||
|
<StatCard label="Discount total" value={`-${formatAmount(discountTotal)}`} icon={Tag} />
|
||||||
|
<StatCard label="Net total" value={formatAmount(netTotal)} icon={CircleDollarSign} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border bg-card shadow-sm">
|
<div className="rounded-2xl border bg-card shadow-sm">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
@@ -203,19 +256,38 @@ export default function SalesInvoicesPage() {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{tabs.map((t) => (
|
<Select<StatusFilter>
|
||||||
<button
|
value={status}
|
||||||
key={t.value}
|
onValueChange={(v) => setStatus(v ?? "All")}
|
||||||
onClick={() => setStatus(t.value)}
|
items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))}
|
||||||
className={cn(
|
>
|
||||||
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
|
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
|
||||||
status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
|
<ListFilter className="size-4 text-muted-foreground" />
|
||||||
)}
|
<SelectValue />
|
||||||
>
|
</SelectTrigger>
|
||||||
{t.label}
|
<SelectContent>
|
||||||
</button>
|
{tabs.map((t) => (
|
||||||
))}
|
<SelectItem key={t.value} value={t.value}>{t.value === "All" ? "All statuses" : t.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select<DateRange>
|
||||||
|
value={dateRange}
|
||||||
|
onValueChange={(v) => setDateRange(v ?? "All")}
|
||||||
|
items={dateRanges.map((r) => ({ label: r.label, value: r.value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10! w-36 text-sm" aria-label="Date range">
|
||||||
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{dateRanges.map((r) => (
|
||||||
|
<SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
@@ -240,21 +312,6 @@ 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">
|
||||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-2 py-2">
|
|
||||||
<div className="text-xs text-muted-foreground">Gross</div>
|
|
||||||
<div className="font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-2 py-2">
|
|
||||||
<div className="text-xs text-muted-foreground">Discount</div>
|
|
||||||
<div className="font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-2 py-2">
|
|
||||||
<div className="text-xs text-muted-foreground">Net</div>
|
|
||||||
<div className="font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{visibleRows.map((row) => (
|
{visibleRows.map((row) => (
|
||||||
<div
|
<div
|
||||||
key={row.salesInvoiceId}
|
key={row.salesInvoiceId}
|
||||||
@@ -303,8 +360,11 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && (
|
<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>
|
<Button type="button" variant="outline" size="sm" onClick={() => setPrintInvoice(row)}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
Print
|
||||||
|
</Button>
|
||||||
{row.status === "Draft" && (
|
{row.status === "Draft" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -317,7 +377,7 @@ export default function SalesInvoicesPage() {
|
|||||||
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{row.totals.balanceAmount > 0 && (
|
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -330,8 +390,7 @@ export default function SalesInvoicesPage() {
|
|||||||
Pay
|
Pay
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -391,6 +450,16 @@ export default function SalesInvoicesPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setPrintInvoice(row)}
|
||||||
|
aria-label={`Print ${row.invoiceNo}`}
|
||||||
|
title="Print"
|
||||||
|
>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
</Button>
|
||||||
{row.status === "Draft" && (
|
{row.status === "Draft" && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -424,26 +493,14 @@ export default function SalesInvoicesPage() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden border-t px-4 py-3 md:block">
|
|
||||||
<div className="grid gap-3 text-sm md:grid-cols-3">
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
|
||||||
<div className="text-muted-foreground">Gross total</div>
|
|
||||||
<div className="text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
|
||||||
<div className="text-muted-foreground">Discount total</div>
|
|
||||||
<div className="text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
|
||||||
<div className="text-muted-foreground">Net total</div>
|
|
||||||
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pagination && (
|
{pagination && (
|
||||||
<div className="hidden border-t px-4 py-4 md:block">
|
<div className="hidden border-t px-4 py-4 md:block">
|
||||||
<PaginationFooter pagination={pagination} onPageChange={setPage} />
|
<PaginationFooter
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={setPage}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={setRowsPerPage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -451,6 +508,12 @@ export default function SalesInvoicesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
<SalesInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||||
|
<SalesInvoicePrintDialog
|
||||||
|
invoiceId={printInvoice?.salesInvoiceId ?? null}
|
||||||
|
invoiceNo={printInvoice?.invoiceNo}
|
||||||
|
open={printInvoice !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) setPrintInvoice(null) }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -253,5 +253,20 @@
|
|||||||
.invoice-sheet th {
|
.invoice-sheet th {
|
||||||
break-inside: avoid;
|
break-inside: avoid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SalesInvoicePrintPreview portals a print copy of the sheet into <body>; while it's there,
|
||||||
|
print only that copy — not the app, the on-screen preview, or an open dialog. */
|
||||||
|
body:has(> .invoice-print-root) > :not(.invoice-print-root) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-print-root {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The print copy never shows on screen. */
|
||||||
|
.invoice-print-root {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ function SalesInvoiceBatchPrintContent() {
|
|||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const status = searchParams.get("status") ?? "All"
|
const status = searchParams.get("status") ?? "All"
|
||||||
const query = searchParams.get("q") ?? ""
|
const query = searchParams.get("q") ?? ""
|
||||||
|
const from = searchParams.get("from") ?? undefined
|
||||||
|
const to = searchParams.get("to") ?? undefined
|
||||||
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
||||||
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)
|
||||||
@@ -40,7 +42,7 @@ function SalesInvoiceBatchPrintContent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
salesApi.listInvoices({ page: 1, pageSize: 200 }),
|
salesApi.listInvoices({ page: 1, pageSize: 200, from, to }),
|
||||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
])
|
])
|
||||||
@@ -50,7 +52,7 @@ function SalesInvoiceBatchPrintContent() {
|
|||||||
setTemplate(templateRes.data)
|
setTemplate(templateRes.data)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [])
|
}, [from, to])
|
||||||
|
|
||||||
const visibleRows = rows?.filter((row) => {
|
const visibleRows = rows?.filter((row) => {
|
||||||
const matchesStatus = status === "All" || row.status === status
|
const matchesStatus = status === "All" || row.status === status
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useEffect, useState, useSyncExternalStore } from "react"
|
||||||
|
import { createPortal } from "react-dom"
|
||||||
|
import { Printer } from "lucide-react"
|
||||||
|
|
||||||
|
import { salesApi } from "@/lib/api/sales"
|
||||||
|
import { customersApi } from "@/lib/api/customers"
|
||||||
|
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||||
|
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||||
|
import { errorMessage } from "@/lib/error-map"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
|
import { CompanyProfile } from "@/types/company-profile"
|
||||||
|
import { Customer } from "@/types/customers"
|
||||||
|
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||||
|
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||||
|
|
||||||
|
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
|
||||||
|
const ACCENT = "#8b1c24"
|
||||||
|
|
||||||
|
type PaperSize = "A4" | "80mm"
|
||||||
|
|
||||||
|
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
||||||
|
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
||||||
|
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
|
||||||
|
const PAGE_RULES: Record<PaperSize, string> = {
|
||||||
|
A4: "@page { size: A4; margin: 12mm; }",
|
||||||
|
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(n: number) {
|
||||||
|
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function qty(n: number) {
|
||||||
|
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dd/MM/yyyy, as on the reference invoice. */
|
||||||
|
function formatDate(value: string | Date) {
|
||||||
|
const d = new Date(value)
|
||||||
|
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything both layouts render, resolved once. */
|
||||||
|
interface InvoiceDoc {
|
||||||
|
invoice: SalesInvoice
|
||||||
|
customer: Customer | null
|
||||||
|
payments: SalesInvoicePayment[]
|
||||||
|
company: CompanyProfile | null
|
||||||
|
template: InvoiceTemplateConfig | null
|
||||||
|
dueDate: Date
|
||||||
|
notes: string[]
|
||||||
|
showLogo: boolean
|
||||||
|
showBank: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
|
||||||
|
|
||||||
|
/** Section heading with the short accent underline. */
|
||||||
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="mb-3">
|
||||||
|
<h2 className="text-base font-bold text-neutral-900">{children}</h2>
|
||||||
|
<div className="mt-1 h-0.5 w-16" style={{ backgroundColor: ACCENT }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) {
|
||||||
|
const t = invoice.totals
|
||||||
|
return (
|
||||||
|
// On screen: a true 210 × 297 mm page. In print the @page margin does the spacing.
|
||||||
|
<div
|
||||||
|
className="invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0"
|
||||||
|
style={printColorExact}
|
||||||
|
>
|
||||||
|
{/* Title + meta */}
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-5xl font-extrabold tracking-wide text-neutral-900">INVOICE</h1>
|
||||||
|
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Invoice No.</dt>
|
||||||
|
<dd>{invoice.invoiceNo}</dd>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Invoice Date</dt>
|
||||||
|
<dd>{formatDate(invoice.invoiceDate)}</dd>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Due Date</dt>
|
||||||
|
<dd>{formatDate(dueDate)}</dd>
|
||||||
|
{invoice.status !== "Posted" && (
|
||||||
|
<>
|
||||||
|
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||||
|
<dd className="font-semibold" style={{ color: ACCENT }}>{invoice.status.toUpperCase()}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
{showLogo && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||||
|
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header rule: heavy dark bar over a thin accent line */}
|
||||||
|
<div className="mt-5 mb-7">
|
||||||
|
<div className="h-1 bg-neutral-900" />
|
||||||
|
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* From / Bill To */}
|
||||||
|
<div className="grid grid-cols-2 gap-10">
|
||||||
|
<div>
|
||||||
|
<SectionTitle>From</SectionTitle>
|
||||||
|
{company ? (
|
||||||
|
<div className="leading-snug">
|
||||||
|
<p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>
|
||||||
|
<p>{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}</p>
|
||||||
|
<p>{[company.city, company.country].filter(Boolean).join(", ")}</p>
|
||||||
|
<div className="mt-2">
|
||||||
|
{company.phone && <p>{company.phone}</p>}
|
||||||
|
{company.email && <p>{company.email}</p>}
|
||||||
|
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg No: {company.taxRegistrationNo}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-neutral-500 print:hidden">Company details aren't set up yet (Settings → Company Profile).</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<SectionTitle>Bill To</SectionTitle>
|
||||||
|
<div className="leading-snug">
|
||||||
|
<p className="font-bold text-neutral-900">{invoice.customerSnapshotName}</p>
|
||||||
|
{customer && (customer.addressLine1 || customer.city) && (
|
||||||
|
<>
|
||||||
|
<p>{[customer.addressLine1, customer.addressLine2].filter(Boolean).join(", ")}</p>
|
||||||
|
<p>{[customer.city, customer.country].filter(Boolean).join(", ")}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="mt-2">
|
||||||
|
{customer?.phone && <p>{customer.phone}</p>}
|
||||||
|
{customer?.email && <p>{customer.email}</p>}
|
||||||
|
{invoice.customerSnapshotTaxNo && <p>Tax No: {invoice.customerSnapshotTaxNo}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line items */}
|
||||||
|
<div className="mt-8">
|
||||||
|
<SectionTitle>Item Details</SectionTitle>
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
|
||||||
|
<th className="px-2 py-2.5 text-left">Item</th>
|
||||||
|
<th className="px-2 py-2.5 text-center">Qty</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Unit Price</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Discount</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Tax</th>
|
||||||
|
<th className="px-2 py-2.5 text-right">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<tr key={line.salesInvoiceLineId} className="border-b border-neutral-300">
|
||||||
|
<td className="px-2 py-2.5">{line.description}{line.isFreeIssue && <span className="ml-1 text-xs text-neutral-500">(free issue)</span>}</td>
|
||||||
|
<td className="px-2 py-2.5 text-center tabular-nums">
|
||||||
|
{qty(line.qty)}
|
||||||
|
{line.freeQty > 0 && <span className="block text-xs text-neutral-500">+ {qty(line.freeQty)} free</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.unitPrice)}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right tabular-nums">{line.discountAmount > 0 ? `-${money(line.discountAmount)}` : "—"}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right tabular-nums">{line.taxAmount > 0 ? money(line.taxAmount) : "—"}</td>
|
||||||
|
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes / totals */}
|
||||||
|
<div className="mt-6 grid grid-cols-[1fr_290px] items-start gap-8 print:break-inside-avoid">
|
||||||
|
<div>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<SectionTitle>Notes / Terms</SectionTitle>
|
||||||
|
<ul className="text-xs">
|
||||||
|
{notes.map((note, i) => (
|
||||||
|
<li key={i} className="border-b border-neutral-300 py-1.5">{note}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-neutral-900">
|
||||||
|
<div className="space-y-1.5 px-3.5 py-3 text-xs">
|
||||||
|
<div className="flex justify-between font-bold text-neutral-900"><span>Subtotal</span><span className="tabular-nums">{money(t.subtotal)}</span></div>
|
||||||
|
{t.discountTotal > 0 && <div className="flex justify-between"><span>Discount</span><span className="tabular-nums">-{money(t.discountTotal)}</span></div>}
|
||||||
|
{t.taxTotal > 0 && <div className="flex justify-between"><span>Tax</span><span className="tabular-nums">{money(t.taxTotal)}</span></div>}
|
||||||
|
{t.roundOff !== 0 && <div className="flex justify-between"><span>Round off</span><span className="tabular-nums">{money(t.roundOff)}</span></div>}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
|
||||||
|
<span>Total</span>
|
||||||
|
<span className="tabular-nums">{money(t.netPayable)}</span>
|
||||||
|
</div>
|
||||||
|
{t.paidAmount > 0 && (
|
||||||
|
<div className="space-y-1.5 border-t border-neutral-300 px-3.5 py-3 text-xs">
|
||||||
|
<div className="flex justify-between"><span>Paid</span><span className="tabular-nums">{money(t.paidAmount)}</span></div>
|
||||||
|
<div className="flex justify-between font-bold text-neutral-900"><span>Balance Due</span><span className="tabular-nums">{money(t.balanceAmount)}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment method */}
|
||||||
|
<div className="mt-8 print:break-inside-avoid">
|
||||||
|
<SectionTitle>Payment Method</SectionTitle>
|
||||||
|
{payments.length > 0 ? (
|
||||||
|
<ul className="text-xs">
|
||||||
|
{payments.map((p) => (
|
||||||
|
<li key={p.salesInvoicePaymentId} className="flex justify-between gap-4 border-b border-neutral-300 py-1.5">
|
||||||
|
<span>
|
||||||
|
{formatDate(p.paymentDate)} · {p.bankAccountName}
|
||||||
|
{p.reference && <span className="text-neutral-500"> · Ref {p.reference}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium tabular-nums">{money(p.amount)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-500">No payment recorded yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showBank && (
|
||||||
|
<div className="mt-3 text-xs leading-relaxed">
|
||||||
|
<p className="font-bold text-neutral-900">Bank transfer</p>
|
||||||
|
{company!.bankName && <p>Bank: {company!.bankName}{company!.bankBranch ? ` — ${company!.bankBranch}` : ""}</p>}
|
||||||
|
{company!.accountName && <p>Account name: {company!.accountName}</p>}
|
||||||
|
{company!.accountNumber && <p>Account number: {company!.accountNumber}</p>}
|
||||||
|
{company!.swiftCode && <p>SWIFT: {company!.swiftCode}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dashed divider used between receipt sections. */
|
||||||
|
function ReceiptRule() {
|
||||||
|
return <div className="my-2 border-t border-dashed border-neutral-500" />
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex justify-between gap-2", bold && "font-bold text-black")}>
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="tabular-nums">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 80mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||||
|
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
|
||||||
|
const t = invoice.totals
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="invoice-sheet mx-auto w-[80mm] shrink-0 bg-white px-[4mm] py-[5mm] text-[11px] leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
|
||||||
|
style={printColorExact}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
{showLogo && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||||
|
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
|
||||||
|
)}
|
||||||
|
{company && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>
|
||||||
|
<p>{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}</p>
|
||||||
|
{company.phone && <p>Tel: {company.phone}</p>}
|
||||||
|
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg: {company.taxRegistrationNo}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-base font-extrabold tracking-[0.2em]">INVOICE</p>
|
||||||
|
{invoice.status !== "Posted" && <p className="font-bold">*** {invoice.status.toUpperCase()} ***</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<ReceiptRow label="Invoice No." value={invoice.invoiceNo} />
|
||||||
|
<ReceiptRow label="Date" value={formatDate(invoice.invoiceDate)} />
|
||||||
|
<ReceiptRow label="Due" value={formatDate(dueDate)} />
|
||||||
|
<ReceiptRow label="Customer" value={invoice.customerSnapshotName} />
|
||||||
|
{customer?.phone && <ReceiptRow label="Phone" value={customer.phone} />}
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<div className="flex justify-between font-bold">
|
||||||
|
<span>Item</span>
|
||||||
|
<span>Amount</span>
|
||||||
|
</div>
|
||||||
|
<ReceiptRule />
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{invoice.lines.map((line) => (
|
||||||
|
<div key={line.salesInvoiceLineId}>
|
||||||
|
<p className="font-medium">{line.description}{line.isFreeIssue ? " (free)" : ""}</p>
|
||||||
|
<div className="flex justify-between gap-2 tabular-nums">
|
||||||
|
<span>
|
||||||
|
{qty(line.qty)} x {money(line.unitPrice)}
|
||||||
|
{line.freeQty > 0 && ` + ${qty(line.freeQty)} free`}
|
||||||
|
</span>
|
||||||
|
<span>{money(line.lineTotal)}</span>
|
||||||
|
</div>
|
||||||
|
{line.discountAmount > 0 && <p className="text-right tabular-nums">Disc -{money(line.discountAmount)}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<ReceiptRow label="Subtotal" value={money(t.subtotal)} />
|
||||||
|
{t.discountTotal > 0 && <ReceiptRow label="Discount" value={`-${money(t.discountTotal)}`} />}
|
||||||
|
{t.taxTotal > 0 && <ReceiptRow label="Tax" value={money(t.taxTotal)} />}
|
||||||
|
{t.roundOff !== 0 && <ReceiptRow label="Round off" value={money(t.roundOff)} />}
|
||||||
|
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||||
|
<ReceiptRow label="TOTAL" value={money(t.netPayable)} bold />
|
||||||
|
</div>
|
||||||
|
{t.paidAmount > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRow label="Paid" value={money(t.paidAmount)} />
|
||||||
|
<ReceiptRow label="Balance" value={money(t.balanceAmount)} bold />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{payments.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRule />
|
||||||
|
<p className="font-bold">Payment</p>
|
||||||
|
{payments.map((p) => (
|
||||||
|
<ReceiptRow key={p.salesInvoicePaymentId} label={`${formatDate(p.paymentDate)} ${p.bankAccountName}`} value={money(p.amount)} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<>
|
||||||
|
<ReceiptRule />
|
||||||
|
{notes.map((note, i) => <p key={i} className="text-center">{note}</p>)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ReceiptRule />
|
||||||
|
<p className="text-center font-bold">Thank you!</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/** Loads the invoice plus the optional decoration around it (customer, payments, company,
|
||||||
|
* template). Only the invoice is required — any other failure just leaves that block out. */
|
||||||
|
function useInvoiceDoc(invoiceId: number) {
|
||||||
|
const [doc, setDoc] = useState<InvoiceDoc | null>(null)
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null)
|
||||||
|
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!Number.isFinite(invoiceId)) return
|
||||||
|
let cancelled = false
|
||||||
|
salesApi
|
||||||
|
.getInvoice(invoiceId)
|
||||||
|
.then(async ({ data: invoice }) => {
|
||||||
|
const [cust, payments, companyRes, templateRes] = await Promise.all([
|
||||||
|
customersApi.get(invoice.customerId).catch(() => null),
|
||||||
|
salesApi.listInvoicePayments(invoiceId).catch(() => []),
|
||||||
|
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
|
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||||
|
])
|
||||||
|
if (cancelled) return
|
||||||
|
const customer = cust?.data ?? null
|
||||||
|
const company = companyRes.data
|
||||||
|
const template = templateRes.data
|
||||||
|
|
||||||
|
// Invoices carry no due date of their own — it's the customer's credit terms from the invoice date.
|
||||||
|
const dueDate = new Date(invoice.invoiceDate)
|
||||||
|
dueDate.setDate(dueDate.getDate() + (customer?.creditDays ?? 0))
|
||||||
|
|
||||||
|
setDoc({
|
||||||
|
invoice,
|
||||||
|
customer,
|
||||||
|
payments,
|
||||||
|
company,
|
||||||
|
template,
|
||||||
|
dueDate,
|
||||||
|
notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")]
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||||
|
showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setLoadError(errorMessage(err))
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [invoiceId])
|
||||||
|
|
||||||
|
return { doc, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribeNoop = () => () => {}
|
||||||
|
|
||||||
|
/** True only on the client — the print copy is portalled into document.body. */
|
||||||
|
function useIsClient() {
|
||||||
|
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||||
|
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet
|
||||||
|
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
|
||||||
|
* just the invoice, whether this is rendered in a page or inside a dialog.
|
||||||
|
*/
|
||||||
|
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
|
||||||
|
const { doc, error } = useInvoiceDoc(invoiceId)
|
||||||
|
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||||
|
const isClient = useIsClient()
|
||||||
|
|
||||||
|
if (error && !doc) {
|
||||||
|
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex min-h-0 flex-col", className)}>
|
||||||
|
{/* Preview window — scrolls so the page keeps its true size. */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
||||||
|
{doc ? (
|
||||||
|
<Sheet size={paperSize} doc={doc} />
|
||||||
|
) : (
|
||||||
|
<Skeleton className="mx-auto h-[297mm] w-[210mm] max-w-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Paper size selection, under the invoice. */}
|
||||||
|
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||||
|
<span className="text-sm font-semibold text-foreground">Paper size</span>
|
||||||
|
<div role="radiogroup" aria-label="Paper size" className="inline-flex w-fit rounded-lg border bg-muted/40 p-1">
|
||||||
|
{PAPER_SIZES.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={paperSize === s.value}
|
||||||
|
onClick={() => setPaperSize(s.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-start rounded-md px-4 py-1.5 text-left transition-colors",
|
||||||
|
paperSize === s.value ? "bg-background text-foreground shadow-sm ring-1 ring-foreground/10" : "text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{s.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||||
|
<Printer className="size-4" />
|
||||||
|
Print {paperSize === "A4" ? "A4" : "receipt"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isClient &&
|
||||||
|
doc &&
|
||||||
|
createPortal(
|
||||||
|
<div className="invoice-print-root">
|
||||||
|
<style>{PAGE_RULES[paperSize]}</style>
|
||||||
|
<Sheet size={paperSize} doc={doc} />
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Popup form of the print preview. Opens on A4 every time (the content remounts per open). */
|
||||||
|
export function SalesInvoicePrintDialog({
|
||||||
|
invoiceId,
|
||||||
|
invoiceNo,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: {
|
||||||
|
invoiceId: number | null
|
||||||
|
invoiceNo?: string
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||||
|
<div className="border-b px-5 py-4 pr-12">
|
||||||
|
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||||
|
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||||
|
</div>
|
||||||
|
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ type SalesReportId = string
|
|||||||
|
|
||||||
export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
||||||
status?: SalesInvoiceStatus
|
status?: SalesInvoiceStatus
|
||||||
|
/** yyyy-MM-dd, inclusive; filters on InvoiceDate. */
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListSalesSlipsParams extends SalesListFilterParams {
|
export interface ListSalesSlipsParams extends SalesListFilterParams {
|
||||||
|
|||||||
Reference in New Issue
Block a user