fixing recipts in sales

This commit is contained in:
2026-09-24 17:02:33 +05:30
parent 9185d39c39
commit d24072e20a
10 changed files with 697 additions and 258 deletions
@@ -25,6 +25,7 @@ import { toast } from "@/components/ui/toast"
import { Label } from "@/components/ui/label"
import { Button } from "@/components/ui/button"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
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 [invoice, setInvoice] = useState<SalesInvoice | null>(null)
const [printOpen, setPrintOpen] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
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>
</div>
</div>
<Link
href={`/print/sales/invoices/${invoice.salesInvoiceId}`}
target="_blank"
rel="noopener noreferrer"
<button
type="button"
onClick={() => setPrintOpen(true)}
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" />
Print
</Link>
</button>
</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}
<section className="bg-white print:bg-white">
@@ -1,197 +1,27 @@
"use client"
import { useEffect, useMemo, useState } from "react"
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 { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { SalesInvoice } from "@/types/sales"
import { buttonVariants } from "@/components/ui/button"
import { SalesInvoicePrintPreview } from "@/components/sales/SalesInvoicePrint"
export default function SalesInvoicePrintPage({ params }: { params: { id: string } }) {
export default function SalesInvoicePrintPage() {
const params = useParams<{ id: string }>()
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 (
<div className="flex flex-col gap-6 print:block print:gap-0">
<div className="flex items-center justify-between gap-3 print:hidden">
<div className="flex items-center gap-3">
<Link href={`/dashboard/sales/invoices/${invoice.salesInvoiceId}`} className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<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 className="flex flex-col gap-6">
<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">
<ArrowLeft className="size-4" />
</Link>
<h1 className="text-2xl font-bold text-foreground">Invoice Print</h1>
</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">
<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>
<SalesInvoicePrintPreview invoiceId={invoiceId} className="overflow-hidden rounded-2xl border shadow-sm" />
</div>
)
}
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
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 { customersApi } from "@/lib/api/customers"
@@ -15,12 +15,13 @@ import { Button, buttonVariants } from "@/components/ui/button"
import { FilterBar } from "@/components/ui/filter-bar"
import { PaginationFooter } from "@/components/ui/pagination-footer"
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 { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog"
import { SalesInvoicePrintDialog } from "@/components/sales/SalesInvoicePrint"
import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
import { PaginationMeta } from "@/types/common"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
@@ -30,10 +31,37 @@ import { cn } from "@/lib/utils"
type StatusFilter = SalesInvoiceStatus | "All"
const PAGE_SIZE = 25
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
// re-fetches from page 1, so previously loaded cards stay put and more append below.
const MOBILE_PAGE_SIZE_STEP = 25
function formatAmount(n: number) {
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
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 }[] = [
{ value: "All", label: "All" },
{ value: "Draft", label: "Draft" },
@@ -50,14 +78,18 @@ export default function SalesInvoicesPage() {
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [dateRange, setDateRange] = useState<DateRange>("All")
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
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 [confirmingId, setConfirmingId] = useState<number | null>(null)
const [payDialogInvoice, setPayDialogInvoice] = useState<SalesInvoiceSummary | null>(null)
const [payDialogOpen, setPayDialogOpen] = useState(false)
const [printInvoice, setPrintInvoice] = useState<SalesInvoiceSummary | null>(null)
const [customerId, setCustomerId] = useState<number | undefined>(undefined)
const [warehouseId, setWarehouseId] = useState<number | undefined>(undefined)
@@ -71,8 +103,8 @@ export default function SalesInvoicesPage() {
useEffect(() => {
setPage(1)
setPageSize(PAGE_SIZE)
}, [query, status, activeBranchId, customerId, warehouseId])
setPageSize(rowsPerPage)
}, [query, status, dateRange, activeBranchId, customerId, warehouseId, rowsPerPage])
useEffect(() => {
customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([]))
@@ -89,6 +121,7 @@ export default function SalesInvoicesPage() {
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
customerId,
warehouseId,
...dateRangeBounds(dateRange),
})
.then((res) => {
setRows(res.items)
@@ -96,11 +129,11 @@ export default function SalesInvoicesPage() {
})
.catch((err) => setError(errorMessage(err)))
.finally(() => setShowMoreLoading(false))
}, [page, pageSize, status, activeBranchId, branchingEnabled, customerId, warehouseId])
}, [page, pageSize, status, dateRange, activeBranchId, branchingEnabled, customerId, warehouseId])
function handleShowMore() {
setShowMoreLoading(true)
setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP)
setPageSize((s) => s + rowsPerPage)
}
async function handleConfirm(row: SalesInvoiceSummary) {
@@ -138,11 +171,14 @@ export default function SalesInvoicesPage() {
) ?? [],
[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 discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 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 (
<div className="flex flex-col gap-6">
@@ -168,7 +204,24 @@ export default function SalesInvoicesPage() {
</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">
<FilterBar
@@ -203,19 +256,38 @@ export default function SalesInvoicesPage() {
</>
}
>
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t.value}
onClick={() => setStatus(t.value)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t.value ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t.label}
</button>
))}
<div className="flex flex-wrap items-center gap-2">
<Select<StatusFilter>
value={status}
onValueChange={(v) => setStatus(v ?? "All")}
items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))}
>
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Status">
<ListFilter className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{tabs.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.value === "All" ? "All statuses" : t.label}</SelectItem>
))}
</SelectContent>
</Select>
<Select<DateRange>
value={dateRange}
onValueChange={(v) => setDateRange(v ?? "All")}
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>
</FilterBar>
@@ -240,21 +312,6 @@ export default function SalesInvoicesPage() {
<>
{/* Mobile: stacked cards. */}
<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) => (
<div
key={row.salesInvoiceId}
@@ -303,8 +360,11 @@ export default function SalesInvoicesPage() {
</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" && (
<Button
type="button"
@@ -317,7 +377,7 @@ export default function SalesInvoicesPage() {
{confirmingId === row.salesInvoiceId ? "Confirming…" : "Confirm"}
</Button>
)}
{row.totals.balanceAmount > 0 && (
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
<Button
type="button"
size="sm"
@@ -330,8 +390,7 @@ export default function SalesInvoicesPage() {
Pay
</Button>
)}
</div>
)}
</div>
</div>
))}
@@ -391,6 +450,16 @@ export default function SalesInvoicesPage() {
</TableCell>
<TableCell className="px-4 py-3.5 text-right" data-no-row-nav>
<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" && (
<Button
type="button"
@@ -424,26 +493,14 @@ export default function SalesInvoicesPage() {
</Table>
</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 && (
<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>
)}
</>
@@ -451,6 +508,12 @@ export default function SalesInvoicesPage() {
</div>
<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>
)
}
+15
View File
@@ -253,5 +253,20 @@
.invoice-sheet th {
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 status = searchParams.get("status") ?? "All"
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 [company, setCompany] = useState<CompanyProfile | null>(null)
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
@@ -40,7 +42,7 @@ function SalesInvoiceBatchPrintContent() {
useEffect(() => {
Promise.all([
salesApi.listInvoices({ page: 1, pageSize: 200 }),
salesApi.listInvoices({ page: 1, pageSize: 200, from, to }),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
@@ -50,7 +52,7 @@ function SalesInvoiceBatchPrintContent() {
setTemplate(templateRes.data)
})
.catch((err) => setError(errorMessage(err)))
}, [])
}, [from, to])
const visibleRows = rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status