diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs index c1f524b..cbbe163 100644 --- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs +++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs @@ -29,8 +29,10 @@ public sealed class SalesInvoicesController : ApiControllerBase [FromQuery] int? customerId, [FromQuery] int? warehouseId, [FromQuery] int? branchId, + [FromQuery] DateOnly? from, + [FromQuery] DateOnly? to, 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}")] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] diff --git a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs index 477abab..dcdacc7 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs @@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces; public interface ISalesInvoiceService { - Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default); + Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default); Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 461f40f..4db6cc6 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService _uow = uow; } - public async Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, CancellationToken ct = default) + public async Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, CancellationToken ct = default) { IQueryable q = _invoices.Query().AsNoTracking().Include(x => x.Lines); 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); // Warehouses are shared across branches — the invoice carries its own owning 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 rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx index c22d004..f94c976 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -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(null) + const [printOpen, setPrintOpen] = useState(false) const [customers, setCustomers] = useState([]) const [items, setItems] = useState([]) const [uoms, setUoms] = useState([]) @@ -302,17 +304,23 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i

{invoice.invoiceNo}

- 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")} > Print - + + + {error ?
{error}
: null}
diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx index 24df1a1..65b04c0 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/print/page.tsx @@ -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(null) - const [customers, setCustomers] = useState([]) - const [items, setItems] = useState([]) - const [uoms, setUoms] = useState([]) - const [warehouses, setWarehouses] = useState([]) - const [error, setError] = useState(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
{error}
- } - - if (!invoice) { - return
{error ?? "Invoice print data is loading or unavailable."}
- } - - const customer = customers.find((c) => c.customerId === invoice.customerId) - const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId) return ( -
-
-
- - - -
-

Invoice Print

-

{invoice.invoiceNo}

-
-
- +
+
+ + + +

Invoice Print

-
-
-
-
Sales Invoice
-

{invoice.invoiceNo}

-
Status: {invoice.status} · Type: {invoice.invoiceType}
-
-
-
ERP Core Trading
-
Company details are not configured for this print view.
-
Invoice date: {new Date(invoice.invoiceDate).toLocaleDateString()}
-
Printed: {new Date().toLocaleString()}
-
Free qty total: {freeQtyTotal.toFixed(2)}
-
-
- -
-
-
Customer
-
{invoice.customerSnapshotName}
-
Customer ID: {invoice.customerId}
- {invoice.customerSnapshotTaxNo ?
Tax No: {invoice.customerSnapshotTaxNo}
: null} - {customer?.displayName ?
Customer: {customer.displayName}
: null} -
-
-
Warehouse
-
{warehouse?.name ?? `#${invoice.warehouseId}`}
-
Code: {warehouse?.code ?? invoice.warehouseId}
-
-
-
Totals
-
-
Subtotal
{invoice.totals.subtotal.toFixed(2)}
-
Discount
{invoice.totals.discountTotal.toFixed(2)}
-
Free qty
{freeQtyTotal.toFixed(2)}
-
Tax
{invoice.totals.taxTotal.toFixed(2)}
-
Net payable
{invoice.totals.netPayable.toFixed(2)}
-
-
-
- -
- - - - Item - UOM - Qty - Free - Unit price - Discount - Tax - Line total - - - - {invoice.lines.map((line) => ( - - -
{line.description}
-
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
-
- {baseUomLabel(items, uoms, line.itemId)} - {line.qty.toFixed(2)} - {line.freeQty > 0 ? line.freeQty.toFixed(2) : "—"} - {line.unitPrice.toFixed(2)} - {line.discountAmount.toFixed(2)} - {line.taxAmount.toFixed(2)} - {line.lineTotal.toFixed(2)} -
- ))} -
-
-
- - {invoice.lines.some((line) => line.freeQty > 0) ? ( -
-
Free issue summary
-
- {invoice.lines.filter((line) => line.freeQty > 0).map((line) => ( -
-
{line.description}
-
Invoice qty: {line.qty.toFixed(2)}
-
Free qty: {line.freeQty.toFixed(2)}
-
- ))} -
-
- ) : null} - -
-
-
Notes
-

Standard invoice print view.

-
-
-
Totals
-
-
Subtotal{invoice.totals.subtotal.toFixed(2)}
-
Discount total{invoice.totals.discountTotal.toFixed(2)}
-
Free qty total{invoice.totals.freeQtyTotal.toFixed(2)}
-
Tax total{invoice.totals.taxTotal.toFixed(2)}
-
Net payable{invoice.totals.netPayable.toFixed(2)}
-
Paid{invoice.totals.paidAmount.toFixed(2)}
-
Balance{invoice.totals.balanceAmount.toFixed(2)}
-
-
-
- -
+
) } diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx index 197e48d..5eabc3b 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -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(null) const [error, setError] = useState(null) const [status, setStatus] = useState("All") + const [dateRange, setDateRange] = useState("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(null) const [payDialogInvoice, setPayDialogInvoice] = useState(null) const [payDialogOpen, setPayDialogOpen] = useState(false) + const [printInvoice, setPrintInvoice] = useState(null) const [customerId, setCustomerId] = useState(undefined) const [warehouseId, setWarehouseId] = useState(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 (
@@ -168,7 +204,24 @@ export default function SalesInvoicesPage() {
- + {/* 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. */} +
+ {stats ? ( + + ) : ( + + )} + {rows !== null ? ( + <> + + + + + ) : ( + Array.from({ length: 3 }).map((_, i) => ) + )} +
} > -
- {tabs.map((t) => ( - - ))} +
+ + value={status} + onValueChange={(v) => setStatus(v ?? "All")} + items={tabs.map((t) => ({ label: t.value === "All" ? "All statuses" : t.label, value: t.value }))} + > + + + + + + {tabs.map((t) => ( + {t.value === "All" ? "All statuses" : t.label} + ))} + + + + + value={dateRange} + onValueChange={(v) => setDateRange(v ?? "All")} + items={dateRanges.map((r) => ({ label: r.label, value: r.value }))} + > + + + + + + {dateRanges.map((r) => ( + {r.label} + ))} + +
@@ -240,21 +312,6 @@ export default function SalesInvoicesPage() { <> {/* Mobile: stacked cards. */}
-
-
-
Gross
-
{grossTotal.toFixed(2)}
-
-
-
Discount
-
-{discountTotal.toFixed(2)}
-
-
-
Net
-
{netTotal.toFixed(2)}
-
-
- {visibleRows.map((row) => (
- {(row.status === "Draft" || (row.status !== "Cancelled" && row.totals.balanceAmount > 0)) && ( -
+
+ {row.status === "Draft" && (
- )} +
))} @@ -391,6 +450,16 @@ export default function SalesInvoicesPage() {
+ {row.status === "Draft" && (
-
-
-
-
Gross total
-
{grossTotal.toFixed(2)}
-
-
-
Discount total
-
-{discountTotal.toFixed(2)}
-
-
-
Net total
-
{netTotal.toFixed(2)}
-
-
-
- {pagination && (
- +
)} @@ -451,6 +508,12 @@ export default function SalesInvoicesPage() {
+ { if (!open) setPrintInvoice(null) }} + />
) } diff --git a/Frontend/erp-system/app/globals.css b/Frontend/erp-system/app/globals.css index da6daf6..76f7e56 100644 --- a/Frontend/erp-system/app/globals.css +++ b/Frontend/erp-system/app/globals.css @@ -253,5 +253,20 @@ .invoice-sheet th { break-inside: avoid; } + + /* SalesInvoicePrintPreview portals a print copy of the sheet into ; 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; } diff --git a/Frontend/erp-system/app/print/sales/invoices/page.tsx b/Frontend/erp-system/app/print/sales/invoices/page.tsx index b7c22ad..09da5b9 100644 --- a/Frontend/erp-system/app/print/sales/invoices/page.tsx +++ b/Frontend/erp-system/app/print/sales/invoices/page.tsx @@ -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(null) const [company, setCompany] = useState(null) const [template, setTemplate] = useState(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 diff --git a/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx b/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx new file mode 100644 index 0000000..9b3ffad --- /dev/null +++ b/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx @@ -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 = { + 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 ( +
+

{children}

+
+
+ ) +} + +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. +
+ {/* Title + meta */} +
+
+

INVOICE

+
+
Invoice No.
+
{invoice.invoiceNo}
+
Invoice Date
+
{formatDate(invoice.invoiceDate)}
+
Due Date
+
{formatDate(dueDate)}
+ {invoice.status !== "Posted" && ( + <> +
Status
+
{invoice.status.toUpperCase()}
+ + )} +
+
+ {showLogo && ( + // eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed + {company!.legalName} + )} +
+ + {/* Header rule: heavy dark bar over a thin accent line */} +
+
+
+
+ + {/* From / Bill To */} +
+
+ From + {company ? ( +
+

{company.tradeName || company.legalName}

+

{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}

+

{[company.city, company.country].filter(Boolean).join(", ")}

+
+ {company.phone &&

{company.phone}

} + {company.email &&

{company.email}

} + {template?.showTaxRegNo && company.taxRegistrationNo &&

Tax Reg No: {company.taxRegistrationNo}

} +
+
+ ) : ( +

Company details aren't set up yet (Settings → Company Profile).

+ )} +
+ +
+ Bill To +
+

{invoice.customerSnapshotName}

+ {customer && (customer.addressLine1 || customer.city) && ( + <> +

{[customer.addressLine1, customer.addressLine2].filter(Boolean).join(", ")}

+

{[customer.city, customer.country].filter(Boolean).join(", ")}

+ + )} +
+ {customer?.phone &&

{customer.phone}

} + {customer?.email &&

{customer.email}

} + {invoice.customerSnapshotTaxNo &&

Tax No: {invoice.customerSnapshotTaxNo}

} +
+
+
+
+ + {/* Line items */} +
+ Item Details + + + + + + + + + + + + + {invoice.lines.map((line) => ( + + + + + + + + + ))} + +
ItemQtyUnit PriceDiscountTaxAmount
{line.description}{line.isFreeIssue && (free issue)} + {qty(line.qty)} + {line.freeQty > 0 && + {qty(line.freeQty)} free} + {money(line.unitPrice)}{line.discountAmount > 0 ? `-${money(line.discountAmount)}` : "—"}{line.taxAmount > 0 ? money(line.taxAmount) : "—"}{money(line.lineTotal)}
+
+ + {/* Notes / totals */} +
+
+ {notes.length > 0 && ( + <> + Notes / Terms +
    + {notes.map((note, i) => ( +
  • {note}
  • + ))} +
+ + )} +
+ +
+
+
Subtotal{money(t.subtotal)}
+ {t.discountTotal > 0 &&
Discount-{money(t.discountTotal)}
} + {t.taxTotal > 0 &&
Tax{money(t.taxTotal)}
} + {t.roundOff !== 0 &&
Round off{money(t.roundOff)}
} +
+
+ Total + {money(t.netPayable)} +
+ {t.paidAmount > 0 && ( +
+
Paid{money(t.paidAmount)}
+
Balance Due{money(t.balanceAmount)}
+
+ )} +
+
+ + {/* Payment method */} +
+ Payment Method + {payments.length > 0 ? ( +
    + {payments.map((p) => ( +
  • + + {formatDate(p.paymentDate)} · {p.bankAccountName} + {p.reference && · Ref {p.reference}} + + {money(p.amount)} +
  • + ))} +
+ ) : ( +

No payment recorded yet.

+ )} + + {showBank && ( +
+

Bank transfer

+ {company!.bankName &&

Bank: {company!.bankName}{company!.bankBranch ? ` — ${company!.bankBranch}` : ""}

} + {company!.accountName &&

Account name: {company!.accountName}

} + {company!.accountNumber &&

Account number: {company!.accountNumber}

} + {company!.swiftCode &&

SWIFT: {company!.swiftCode}

} +
+ )} +
+
+ ) +} + +/** Dashed divider used between receipt sections. */ +function ReceiptRule() { + return
+} + +function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { + return ( +
+ {label} + {value} +
+ ) +} + +/** 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 ( +
+
+ {showLogo && ( + // eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed + {company!.legalName} + )} + {company && ( + <> +

{company.tradeName || company.legalName}

+

{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}

+ {company.phone &&

Tel: {company.phone}

} + {template?.showTaxRegNo && company.taxRegistrationNo &&

Tax Reg: {company.taxRegistrationNo}

} + + )} +

INVOICE

+ {invoice.status !== "Posted" &&

*** {invoice.status.toUpperCase()} ***

} +
+ + + + + + + {customer?.phone && } + + +
+ Item + Amount +
+ +
+ {invoice.lines.map((line) => ( +
+

{line.description}{line.isFreeIssue ? " (free)" : ""}

+
+ + {qty(line.qty)} x {money(line.unitPrice)} + {line.freeQty > 0 && ` + ${qty(line.freeQty)} free`} + + {money(line.lineTotal)} +
+ {line.discountAmount > 0 &&

Disc -{money(line.discountAmount)}

} +
+ ))} +
+ + + + {t.discountTotal > 0 && } + {t.taxTotal > 0 && } + {t.roundOff !== 0 && } +
+ +
+ {t.paidAmount > 0 && ( + <> + + + + )} + + {payments.length > 0 && ( + <> + +

Payment

+ {payments.map((p) => ( + + ))} + + )} + + {notes.length > 0 && ( + <> + + {notes.map((note, i) =>

{note}

)} + + )} + + +

Thank you!

+
+ ) +} +/** 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(null) + const [loadError, setLoadError] = useState(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" ? : +} + +/** + * Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet + * portalled straight into (`.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("A4") + const isClient = useIsClient() + + if (error && !doc) { + return
{error}
+ } + + return ( +
+ {/* Preview window — scrolls so the page keeps its true size. */} +
+ {doc ? ( + + ) : ( + + )} +
+ + {/* Paper size selection, under the invoice. */} +
+
+ Paper size +
+ {PAPER_SIZES.map((s) => ( + + ))} +
+
+ +
+ + {isClient && + doc && + createPortal( +
+ + +
, + document.body + )} +
+ ) +} + +/** 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 ( + + +
+ Invoice{invoiceNo ? ` ${invoiceNo}` : ""} + Preview, pick a paper size below, then print. +
+ {invoiceId !== null && } +
+
+ ) +} diff --git a/Frontend/erp-system/lib/api/sales.ts b/Frontend/erp-system/lib/api/sales.ts index ef3deb4..8147d6c 100644 --- a/Frontend/erp-system/lib/api/sales.ts +++ b/Frontend/erp-system/lib/api/sales.ts @@ -28,6 +28,9 @@ type SalesReportId = string export interface ListSalesInvoicesParams extends SalesListFilterParams { status?: SalesInvoiceStatus + /** yyyy-MM-dd, inclusive; filters on InvoiceDate. */ + from?: string + to?: string } export interface ListSalesSlipsParams extends SalesListFilterParams {