Merge pull request 'Dev' (#91) from Dev into production

Reviewed-on: #91
This commit was merged in pull request #91.
This commit is contained in:
2026-09-25 09:04:19 +00:00
10 changed files with 697 additions and 258 deletions
@@ -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>
) )
} }
+15
View File
@@ -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&apos;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>
)
}
+3
View File
@@ -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 {