Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd82f5b5c4 | |||
| 268b6562dc | |||
| b389c01dfe | |||
| d24072e20a | |||
| 9185d39c39 | |||
| a2790c13bb | |||
| 940583552a | |||
| f35439e364 |
@@ -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)]
|
||||
|
||||
@@ -23,7 +23,12 @@ public sealed record DashboardStatsDto(
|
||||
int PendingCounts,
|
||||
int OpenRfqs,
|
||||
int PendingLeaveRequests,
|
||||
int OverdueSalesInvoices);
|
||||
int OverdueSalesInvoices,
|
||||
IReadOnlyList<MonthlyPurchasesVsSalesDto> MonthlyPurchasesVsSales);
|
||||
|
||||
/// <summary>One bar in the Stock Valuation chart — total FIFO layer value for a warehouse.</summary>
|
||||
public sealed record WarehouseValuationDto(int WarehouseId, decimal Total);
|
||||
|
||||
/// <summary>One month in the Monthly Purchases chart (Purchase Invoices page) — GrandTotal
|
||||
/// of Posted purchase invoices vs Posted sales invoices. <c>Month</c> is "yyyy-MM".</summary>
|
||||
public sealed record MonthlyPurchasesVsSalesDto(string Month, decimal Purchases, decimal Sales);
|
||||
|
||||
@@ -26,6 +26,7 @@ public sealed class DashboardService : IDashboardService
|
||||
private readonly IRepository<Rfq> _rfqs;
|
||||
private readonly IRepository<LeaveRequest> _leaveRequests;
|
||||
private readonly IRepository<SalesInvoice> _salesInvoices;
|
||||
private readonly IRepository<PurchaseInvoice> _purchaseInvoices;
|
||||
private readonly IReorderService _reorder;
|
||||
|
||||
// Neither SalesInvoice nor PurchaseInvoice carries a DueDate field (Phase 1 schema
|
||||
@@ -33,11 +34,14 @@ public sealed class DashboardService : IDashboardService
|
||||
// balance older than this many days from its invoice date.
|
||||
private const int OverdueAgeDays = 30;
|
||||
|
||||
// Monthly Purchases chart window: the current month plus the previous 11.
|
||||
private const int ChartMonths = 12;
|
||||
|
||||
public DashboardService(
|
||||
IRepository<StockLayer> layers, IRepository<Grn> grns, IRepository<PurchaseOrder> pos,
|
||||
IRepository<Requisition> requisitions, IRepository<StockCount> counts, IRepository<Rfq> rfqs,
|
||||
IRepository<LeaveRequest> leaveRequests, IRepository<SalesInvoice> salesInvoices,
|
||||
IReorderService reorder)
|
||||
IRepository<PurchaseInvoice> purchaseInvoices, IReorderService reorder)
|
||||
{
|
||||
_layers = layers;
|
||||
_grns = grns;
|
||||
@@ -47,6 +51,7 @@ public sealed class DashboardService : IDashboardService
|
||||
_rfqs = rfqs;
|
||||
_leaveRequests = leaveRequests;
|
||||
_salesInvoices = salesInvoices;
|
||||
_purchaseInvoices = purchaseInvoices;
|
||||
_reorder = reorder;
|
||||
}
|
||||
|
||||
@@ -103,9 +108,50 @@ public sealed class DashboardService : IDashboardService
|
||||
var lowStockAlerts = (await _reorder.GetAlertsAsync(null, branchId, new PageQuery { Page = 1, PageSize = 1 }, ct))
|
||||
.Pagination.TotalItems;
|
||||
|
||||
var monthlyPurchasesVsSales = await GetMonthlyPurchasesVsSalesAsync(branchId, salesInvoices, ct);
|
||||
|
||||
return new DashboardStatsDto(
|
||||
lowStockAlerts, onHandTotal, onHandWarehouses, stockValuationTotal, stockValuationByWarehouse,
|
||||
pendingApprovalPOs, pendingGrns, openRequisitions, pendingCounts, openRfqs,
|
||||
pendingLeaveRequests, overdueSalesInvoices);
|
||||
pendingLeaveRequests, overdueSalesInvoices, monthlyPurchasesVsSales);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posted purchase vs sales invoice GrandTotals per calendar month (UTC) over the last
|
||||
/// <see cref="ChartMonths"/> months, oldest first. Months with neither are omitted, so an
|
||||
/// empty list means "no posted invoices yet". Purchases are dated by VendorInvoiceDate,
|
||||
/// sales by InvoiceDate.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyList<MonthlyPurchasesVsSalesDto>> GetMonthlyPurchasesVsSalesAsync(
|
||||
int? branchId, IQueryable<SalesInvoice> salesInvoices, CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var since = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-(ChartMonths - 1));
|
||||
|
||||
var purchaseInvoices = _purchaseInvoices.Query().AsNoTracking();
|
||||
if (branchId is not null) purchaseInvoices = purchaseInvoices.Where(i => i.BranchId == branchId);
|
||||
var purchases = await purchaseInvoices
|
||||
.Where(i => i.Status == PurchaseInvoiceStatus.Posted && i.VendorInvoiceDate >= since)
|
||||
.GroupBy(i => new { i.VendorInvoiceDate.Year, i.VendorInvoiceDate.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var sales = await salesInvoices
|
||||
.Where(i => i.Status == SalesInvoiceStatus.Posted && i.InvoiceDate >= since)
|
||||
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
|
||||
.Select(g => new { g.Key.Year, g.Key.Month, Total = g.Sum(i => i.GrandTotal) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var purchasesByMonth = purchases.ToDictionary(p => (p.Year, p.Month), p => p.Total);
|
||||
var salesByMonth = sales.ToDictionary(s => (s.Year, s.Month), s => s.Total);
|
||||
|
||||
return Enumerable.Range(0, ChartMonths)
|
||||
.Select(offset => since.AddMonths(offset))
|
||||
.Select(m => new MonthlyPurchasesVsSalesDto(
|
||||
$"{m.Year:D4}-{m.Month:D2}",
|
||||
purchasesByMonth.GetValueOrDefault((m.Year, m.Month)),
|
||||
salesByMonth.GetValueOrDefault((m.Year, m.Month))))
|
||||
.Where(m => m.Purchases != 0m || m.Sales != 0m)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ERPCore.Services.Interfaces;
|
||||
|
||||
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<SalesInvoicePostingCheckDto> CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default);
|
||||
Task<ETagged<SalesInvoiceDto>> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default);
|
||||
|
||||
@@ -51,7 +51,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService
|
||||
_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);
|
||||
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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||
|
||||
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
|
||||
const ACCENT = "#8b1c24"
|
||||
|
||||
type PaperSize = "A4" | "80mm"
|
||||
|
||||
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
||||
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
||||
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
|
||||
]
|
||||
|
||||
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
|
||||
const PAGE_RULES: Record<PaperSize, string> = {
|
||||
A4: "@page { size: A4; margin: 12mm; }",
|
||||
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
|
||||
}
|
||||
|
||||
function money(n: number) {
|
||||
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function qty(n: number) {
|
||||
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
|
||||
}
|
||||
|
||||
/** dd/MM/yyyy, as on the reference invoice. */
|
||||
function formatDate(value: string | Date) {
|
||||
const d = new Date(value)
|
||||
return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}`
|
||||
}
|
||||
|
||||
/** Everything both layouts render, resolved once. */
|
||||
interface InvoiceDoc {
|
||||
invoice: SalesInvoice
|
||||
customer: Customer | null
|
||||
payments: SalesInvoicePayment[]
|
||||
company: CompanyProfile | null
|
||||
template: InvoiceTemplateConfig | null
|
||||
dueDate: Date
|
||||
notes: string[]
|
||||
showLogo: boolean
|
||||
showBank: boolean
|
||||
}
|
||||
|
||||
const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
|
||||
|
||||
/** Section heading with the short accent underline. */
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<h2 className="text-base font-bold text-neutral-900">{children}</h2>
|
||||
<div className="mt-1 h-0.5 w-16" style={{ backgroundColor: ACCENT }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) {
|
||||
const t = invoice.totals
|
||||
return (
|
||||
// On screen: a true 210 × 297 mm page. In print the @page margin does the spacing.
|
||||
<div
|
||||
className="invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0"
|
||||
style={printColorExact}
|
||||
>
|
||||
{/* Title + meta */}
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-5xl font-extrabold tracking-wide text-neutral-900">INVOICE</h1>
|
||||
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
|
||||
<dt className="text-xs font-bold text-neutral-900">Invoice No.</dt>
|
||||
<dd>{invoice.invoiceNo}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Invoice Date</dt>
|
||||
<dd>{formatDate(invoice.invoiceDate)}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Due Date</dt>
|
||||
<dd>{formatDate(dueDate)}</dd>
|
||||
{invoice.status !== "Posted" && (
|
||||
<>
|
||||
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||
<dd className="font-semibold" style={{ color: ACCENT }}>{invoice.status.toUpperCase()}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Header rule: heavy dark bar over a thin accent line */}
|
||||
<div className="mt-5 mb-7">
|
||||
<div className="h-1 bg-neutral-900" />
|
||||
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
|
||||
</div>
|
||||
|
||||
{/* From / Bill To */}
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<SectionTitle>From</SectionTitle>
|
||||
{company ? (
|
||||
<div className="leading-snug">
|
||||
<p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>
|
||||
<p>{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}</p>
|
||||
<p>{[company.city, company.country].filter(Boolean).join(", ")}</p>
|
||||
<div className="mt-2">
|
||||
{company.phone && <p>{company.phone}</p>}
|
||||
{company.email && <p>{company.email}</p>}
|
||||
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg No: {company.taxRegistrationNo}</p>}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-neutral-500 print:hidden">Company details aren't set up yet (Settings → Company Profile).</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SectionTitle>Bill To</SectionTitle>
|
||||
<div className="leading-snug">
|
||||
<p className="font-bold text-neutral-900">{invoice.customerSnapshotName}</p>
|
||||
{customer && (customer.addressLine1 || customer.city) && (
|
||||
<>
|
||||
<p>{[customer.addressLine1, customer.addressLine2].filter(Boolean).join(", ")}</p>
|
||||
<p>{[customer.city, customer.country].filter(Boolean).join(", ")}</p>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{customer?.phone && <p>{customer.phone}</p>}
|
||||
{customer?.email && <p>{customer.email}</p>}
|
||||
{invoice.customerSnapshotTaxNo && <p>Tax No: {invoice.customerSnapshotTaxNo}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line items */}
|
||||
<div className="mt-8">
|
||||
<SectionTitle>Item Details</SectionTitle>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
|
||||
<th className="px-2 py-2.5 text-left">Item</th>
|
||||
<th className="px-2 py-2.5 text-center">Qty</th>
|
||||
<th className="px-2 py-2.5 text-right">Unit Price</th>
|
||||
<th className="px-2 py-2.5 text-right">Discount</th>
|
||||
<th className="px-2 py-2.5 text-right">Tax</th>
|
||||
<th className="px-2 py-2.5 text-right">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoice.lines.map((line) => (
|
||||
<tr key={line.salesInvoiceLineId} className="border-b border-neutral-300">
|
||||
<td className="px-2 py-2.5">{line.description}{line.isFreeIssue && <span className="ml-1 text-xs text-neutral-500">(free issue)</span>}</td>
|
||||
<td className="px-2 py-2.5 text-center tabular-nums">
|
||||
{qty(line.qty)}
|
||||
{line.freeQty > 0 && <span className="block text-xs text-neutral-500">+ {qty(line.freeQty)} free</span>}
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.unitPrice)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{line.discountAmount > 0 ? `-${money(line.discountAmount)}` : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{line.taxAmount > 0 ? money(line.taxAmount) : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Notes / totals */}
|
||||
<div className="mt-6 grid grid-cols-[1fr_290px] items-start gap-8 print:break-inside-avoid">
|
||||
<div>
|
||||
{notes.length > 0 && (
|
||||
<>
|
||||
<SectionTitle>Notes / Terms</SectionTitle>
|
||||
<ul className="text-xs">
|
||||
{notes.map((note, i) => (
|
||||
<li key={i} className="border-b border-neutral-300 py-1.5">{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border border-neutral-900">
|
||||
<div className="space-y-1.5 px-3.5 py-3 text-xs">
|
||||
<div className="flex justify-between font-bold text-neutral-900"><span>Subtotal</span><span className="tabular-nums">{money(t.subtotal)}</span></div>
|
||||
{t.discountTotal > 0 && <div className="flex justify-between"><span>Discount</span><span className="tabular-nums">-{money(t.discountTotal)}</span></div>}
|
||||
{t.taxTotal > 0 && <div className="flex justify-between"><span>Tax</span><span className="tabular-nums">{money(t.taxTotal)}</span></div>}
|
||||
{t.roundOff !== 0 && <div className="flex justify-between"><span>Round off</span><span className="tabular-nums">{money(t.roundOff)}</span></div>}
|
||||
</div>
|
||||
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
|
||||
<span>Total</span>
|
||||
<span className="tabular-nums">{money(t.netPayable)}</span>
|
||||
</div>
|
||||
{t.paidAmount > 0 && (
|
||||
<div className="space-y-1.5 border-t border-neutral-300 px-3.5 py-3 text-xs">
|
||||
<div className="flex justify-between"><span>Paid</span><span className="tabular-nums">{money(t.paidAmount)}</span></div>
|
||||
<div className="flex justify-between font-bold text-neutral-900"><span>Balance Due</span><span className="tabular-nums">{money(t.balanceAmount)}</span></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment method */}
|
||||
<div className="mt-8 print:break-inside-avoid">
|
||||
<SectionTitle>Payment Method</SectionTitle>
|
||||
{payments.length > 0 ? (
|
||||
<ul className="text-xs">
|
||||
{payments.map((p) => (
|
||||
<li key={p.salesInvoicePaymentId} className="flex justify-between gap-4 border-b border-neutral-300 py-1.5">
|
||||
<span>
|
||||
{formatDate(p.paymentDate)} · {p.bankAccountName}
|
||||
{p.reference && <span className="text-neutral-500"> · Ref {p.reference}</span>}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums">{money(p.amount)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-500">No payment recorded yet.</p>
|
||||
)}
|
||||
|
||||
{showBank && (
|
||||
<div className="mt-3 text-xs leading-relaxed">
|
||||
<p className="font-bold text-neutral-900">Bank transfer</p>
|
||||
{company!.bankName && <p>Bank: {company!.bankName}{company!.bankBranch ? ` — ${company!.bankBranch}` : ""}</p>}
|
||||
{company!.accountName && <p>Account name: {company!.accountName}</p>}
|
||||
{company!.accountNumber && <p>Account number: {company!.accountNumber}</p>}
|
||||
{company!.swiftCode && <p>SWIFT: {company!.swiftCode}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Dashed divider used between receipt sections. */
|
||||
function ReceiptRule() {
|
||||
return <div className="my-2 border-t border-dashed border-neutral-500" />
|
||||
}
|
||||
|
||||
function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
|
||||
return (
|
||||
<div className={cn("flex justify-between gap-2", bold && "font-bold text-black")}>
|
||||
<span>{label}</span>
|
||||
<span className="tabular-nums">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 80mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
|
||||
const t = invoice.totals
|
||||
return (
|
||||
<div
|
||||
className="invoice-sheet mx-auto w-[80mm] shrink-0 bg-white px-[4mm] py-[5mm] text-[11px] leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
|
||||
style={printColorExact}
|
||||
>
|
||||
<div className="text-center">
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
|
||||
)}
|
||||
{company && (
|
||||
<>
|
||||
<p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>
|
||||
<p>{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}</p>
|
||||
{company.phone && <p>Tel: {company.phone}</p>}
|
||||
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg: {company.taxRegistrationNo}</p>}
|
||||
</>
|
||||
)}
|
||||
<p className="mt-2 text-base font-extrabold tracking-[0.2em]">INVOICE</p>
|
||||
{invoice.status !== "Posted" && <p className="font-bold">*** {invoice.status.toUpperCase()} ***</p>}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="Invoice No." value={invoice.invoiceNo} />
|
||||
<ReceiptRow label="Date" value={formatDate(invoice.invoiceDate)} />
|
||||
<ReceiptRow label="Due" value={formatDate(dueDate)} />
|
||||
<ReceiptRow label="Customer" value={invoice.customerSnapshotName} />
|
||||
{customer?.phone && <ReceiptRow label="Phone" value={customer.phone} />}
|
||||
|
||||
<ReceiptRule />
|
||||
<div className="flex justify-between font-bold">
|
||||
<span>Item</span>
|
||||
<span>Amount</span>
|
||||
</div>
|
||||
<ReceiptRule />
|
||||
<div className="space-y-1.5">
|
||||
{invoice.lines.map((line) => (
|
||||
<div key={line.salesInvoiceLineId}>
|
||||
<p className="font-medium">{line.description}{line.isFreeIssue ? " (free)" : ""}</p>
|
||||
<div className="flex justify-between gap-2 tabular-nums">
|
||||
<span>
|
||||
{qty(line.qty)} x {money(line.unitPrice)}
|
||||
{line.freeQty > 0 && ` + ${qty(line.freeQty)} free`}
|
||||
</span>
|
||||
<span>{money(line.lineTotal)}</span>
|
||||
</div>
|
||||
{line.discountAmount > 0 && <p className="text-right tabular-nums">Disc -{money(line.discountAmount)}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="Subtotal" value={money(t.subtotal)} />
|
||||
{t.discountTotal > 0 && <ReceiptRow label="Discount" value={`-${money(t.discountTotal)}`} />}
|
||||
{t.taxTotal > 0 && <ReceiptRow label="Tax" value={money(t.taxTotal)} />}
|
||||
{t.roundOff !== 0 && <ReceiptRow label="Round off" value={money(t.roundOff)} />}
|
||||
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||
<ReceiptRow label="TOTAL" value={money(t.netPayable)} bold />
|
||||
</div>
|
||||
{t.paidAmount > 0 && (
|
||||
<>
|
||||
<ReceiptRow label="Paid" value={money(t.paidAmount)} />
|
||||
<ReceiptRow label="Balance" value={money(t.balanceAmount)} bold />
|
||||
</>
|
||||
)}
|
||||
|
||||
{payments.length > 0 && (
|
||||
<>
|
||||
<ReceiptRule />
|
||||
<p className="font-bold">Payment</p>
|
||||
{payments.map((p) => (
|
||||
<ReceiptRow key={p.salesInvoicePaymentId} label={`${formatDate(p.paymentDate)} ${p.bankAccountName}`} value={money(p.amount)} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{notes.length > 0 && (
|
||||
<>
|
||||
<ReceiptRule />
|
||||
{notes.map((note, i) => <p key={i} className="text-center">{note}</p>)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ReceiptRule />
|
||||
<p className="text-center font-bold">Thank you!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/** Loads the invoice plus the optional decoration around it (customer, payments, company,
|
||||
* template). Only the invoice is required — any other failure just leaves that block out. */
|
||||
function useInvoiceDoc(invoiceId: number) {
|
||||
const [doc, setDoc] = useState<InvoiceDoc | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(invoiceId)) return
|
||||
let cancelled = false
|
||||
salesApi
|
||||
.getInvoice(invoiceId)
|
||||
.then(async ({ data: invoice }) => {
|
||||
const [cust, payments, companyRes, templateRes] = await Promise.all([
|
||||
customersApi.get(invoice.customerId).catch(() => null),
|
||||
salesApi.listInvoicePayments(invoiceId).catch(() => []),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
if (cancelled) return
|
||||
const customer = cust?.data ?? null
|
||||
const company = companyRes.data
|
||||
const template = templateRes.data
|
||||
|
||||
// Invoices carry no due date of their own — it's the customer's credit terms from the invoice date.
|
||||
const dueDate = new Date(invoice.invoiceDate)
|
||||
dueDate.setDate(dueDate.getDate() + (customer?.creditDays ?? 0))
|
||||
|
||||
setDoc({
|
||||
invoice,
|
||||
customer,
|
||||
payments,
|
||||
company,
|
||||
template,
|
||||
dueDate,
|
||||
notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")]
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean),
|
||||
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||
showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber),
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [invoiceId])
|
||||
|
||||
return { doc, error }
|
||||
}
|
||||
|
||||
const subscribeNoop = () => () => {}
|
||||
|
||||
/** True only on the client — the print copy is portalled into document.body. */
|
||||
function useIsClient() {
|
||||
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet
|
||||
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
|
||||
* just the invoice, whether this is rendered in a page or inside a dialog.
|
||||
*/
|
||||
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
|
||||
const { doc, error } = useInvoiceDoc(invoiceId)
|
||||
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||
const isClient = useIsClient()
|
||||
|
||||
if (error && !doc) {
|
||||
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-col", className)}>
|
||||
{/* Preview window — scrolls so the page keeps its true size. */}
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
||||
{doc ? (
|
||||
<Sheet size={paperSize} doc={doc} />
|
||||
) : (
|
||||
<Skeleton className="mx-auto h-[297mm] w-[210mm] max-w-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Paper size selection, under the invoice. */}
|
||||
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="text-sm font-semibold text-foreground">Paper size</span>
|
||||
<div role="radiogroup" aria-label="Paper size" className="inline-flex w-fit rounded-lg border bg-muted/40 p-1">
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={paperSize === s.value}
|
||||
onClick={() => setPaperSize(s.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-start rounded-md px-4 py-1.5 text-left transition-colors",
|
||||
paperSize === s.value ? "bg-background text-foreground shadow-sm ring-1 ring-foreground/10" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||
<Printer className="size-4" />
|
||||
Print {paperSize === "A4" ? "A4" : "receipt"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isClient &&
|
||||
doc &&
|
||||
createPortal(
|
||||
<div className="invoice-print-root">
|
||||
<style>{PAGE_RULES[paperSize]}</style>
|
||||
<Sheet size={paperSize} doc={doc} />
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Popup form of the print preview. Opens on A4 every time (the content remounts per open). */
|
||||
export function SalesInvoicePrintDialog({
|
||||
invoiceId,
|
||||
invoiceNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
invoiceId: number | null
|
||||
invoiceNo?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||
<div className="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
</div>
|
||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,9 @@ type SalesReportId = string
|
||||
|
||||
export interface ListSalesInvoicesParams extends SalesListFilterParams {
|
||||
status?: SalesInvoiceStatus
|
||||
/** yyyy-MM-dd, inclusive; filters on InvoiceDate. */
|
||||
from?: string
|
||||
to?: string
|
||||
}
|
||||
|
||||
export interface ListSalesSlipsParams extends SalesListFilterParams {
|
||||
|
||||
@@ -19,4 +19,13 @@ export interface DashboardStats {
|
||||
openRfqs: number
|
||||
pendingLeaveRequests: number
|
||||
overdueSalesInvoices: number
|
||||
monthlyPurchasesVsSales: MonthlyPurchasesVsSales[]
|
||||
}
|
||||
|
||||
// One month of Posted purchase vs sales invoice GrandTotals; `month` is "yyyy-MM".
|
||||
// Only months with activity are returned (last 12, oldest first).
|
||||
export interface MonthlyPurchasesVsSales {
|
||||
month: string
|
||||
purchases: number
|
||||
sales: number
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user