ui bug fixers

This commit is contained in:
2026-10-07 10:28:29 +05:30
committed by ImanThiyanga
parent 0d3f2fba14
commit 3abcfa6e6f
37 changed files with 1852 additions and 542 deletions
@@ -0,0 +1,253 @@
"use client"
import { useEffect, useState } from "react"
import { customersApi } from "@/lib/api/customers"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { Customer, CustomerType } from "@/types/customers"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
/**
* Edit popup for one customer, opened from the customers list and laid out like its
* "New customer" dialog. Mount it with `key={customerId}` so each open starts clean. The
* list rows carry no ETag, so the customer is re-read here for a fresh If-Match first.
*/
export function EditCustomerDialog({
customerId,
onClose,
onSaved,
}: {
customerId: number
onClose: () => void
onSaved: (customer: Customer) => void
}) {
const [customer, setCustomer] = useState<Customer | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [customerCode, setCustomerCode] = useState("")
const [customerType, setCustomerType] = useState<CustomerType>("B2C")
const [name, setName] = useState("")
const [phone, setPhone] = useState("")
const [email, setEmail] = useState("")
const [addressLine1, setAddressLine1] = useState("")
const [addressLine2, setAddressLine2] = useState("")
const [city, setCity] = useState("")
const [country, setCountry] = useState("")
const [taxRegistrationNo, setTaxRegistrationNo] = useState("")
const [creditLimit, setCreditLimit] = useState("")
const [creditDays, setCreditDays] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
function applyCustomer(data: Customer, tag: string | null) {
setCustomer(data)
setEtag(tag)
setCustomerCode(data.customerCode)
setCustomerType(data.customerType)
setName(data.name)
setPhone(data.phone ?? "")
setEmail(data.email ?? "")
setAddressLine1(data.addressLine1 ?? "")
setAddressLine2(data.addressLine2 ?? "")
setCity(data.city ?? "")
setCountry(data.country ?? "")
setTaxRegistrationNo(data.taxRegistrationNo ?? "")
setCreditLimit(String(data.creditLimit))
setCreditDays(String(data.creditDays))
}
function reload() {
return customersApi.get(customerId).then(({ data, etag: tag }) => applyCustomer(data, tag))
}
useEffect(() => {
reload().catch((err) => setLoadError(errorMessage(err)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [customerId])
/** Drops a field's error once the user starts editing it. */
function clearError(key: string) {
setErrors((prev) => {
if (!prev[key]) return prev
const next = { ...prev }
delete next[key]
return next
})
}
async function handleSave() {
const nextErrors: Record<string, string> = {}
if (!customerCode.trim()) nextErrors.customerCode = "Customer code is required"
if (!name.trim()) nextErrors.name = "Customer name is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not save customer", Object.values(nextErrors)[0])
return
}
if (!customer || !etag) return
setSaving(true)
try {
const result = await customersApi.update(
customerId,
{
customerCode,
customerType,
name,
// Not editable in this popup — send the stored value back so the PUT doesn't clear it.
displayName: customer.displayName,
phone: phone || null,
email: email || null,
addressLine1: addressLine1 || null,
addressLine2: addressLine2 || null,
city: city || null,
country: country || null,
taxRegistrationNo: taxRegistrationNo || null,
creditLimit: Number(creditLimit) || 0,
creditDays: Number(creditDays) || 0,
defaultWarehouseId: customer.defaultWarehouseId,
branchId: customer.branchId,
},
etag
)
toast.success("Customer saved", `${result.data.customerCode} — ${result.data.name}`)
onSaved(result.data)
} catch (err) {
if ((err as { code?: string })?.code === "CONCURRENCY_CONFLICT") {
// Someone else saved first: show their version so the user can redo the edit on top of it.
toast.error("Customer was changed by someone else", "The latest details have been loaded — make your edit again.")
await reload().catch(() => {})
return
}
const fe = fieldErrors(err)
if (fe?.customerCode) setErrors({ customerCode: fe.customerCode })
toast.error("Could not save customer", errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit customer</DialogTitle>
<DialogDescription>Update this customer&apos;s details.</DialogDescription>
</DialogHeader>
{loadError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{loadError}</div>
) : !customer ? (
<div className="flex flex-col gap-5">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : (
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ec-name" required>Name</FieldLabel>
<Input
id="ec-name"
value={name}
onChange={(e) => {
setName(e.target.value)
clearError("name")
}}
placeholder="Nimal Perera"
aria-invalid={!!errors.name}
/>
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={!!errors.customerCode}>
<FieldLabel htmlFor="ec-code" required>Code</FieldLabel>
<Input
id="ec-code"
value={customerCode}
onChange={(e) => {
setCustomerCode(e.target.value)
clearError("customerCode")
}}
aria-invalid={!!errors.customerCode}
/>
<FieldError errors={[errors.customerCode ? { message: errors.customerCode } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="ec-type">Customer type</FieldLabel>
<Select<CustomerType> value={customerType} onValueChange={(v) => v && setCustomerType(v)}>
<SelectTrigger id="ec-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="B2C">B2C — individual</SelectItem>
<SelectItem value="B2B">B2B — business</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="ec-phone">Phone (optional)</FieldLabel>
<Input id="ec-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
</Field>
<Field>
<FieldLabel htmlFor="ec-email">Email (optional)</FieldLabel>
<Input id="ec-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
</Field>
</div>
<Field>
<FieldLabel htmlFor="ec-addr1">Address line 1 (optional)</FieldLabel>
<Input id="ec-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ec-addr2">Address line 2 (optional)</FieldLabel>
<Input id="ec-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="ec-city">City (optional)</FieldLabel>
<Input id="ec-city" value={city} onChange={(e) => setCity(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ec-country">Country (optional)</FieldLabel>
<Input id="ec-country" value={country} onChange={(e) => setCountry(e.target.value)} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="ec-tax">Tax registration (optional)</FieldLabel>
<Input id="ec-tax" value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="ec-credit-limit">Credit limit (LKR)</FieldLabel>
<Input id="ec-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="ec-credit-days">Credit days</FieldLabel>
<Input id="ec-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
</Field>
</div>
</FieldGroup>
)}
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSave} disabled={saving || !customer}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,286 @@
"use client"
import { ReactNode, useRef, useState, useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { Download, Loader2, Printer, Share2 } from "lucide-react"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
/*
* Shared plumbing for the printable invoice previews (sales and purchase): paper sizes, the
* sheet's building blocks, PDF export, and the preview frame with its Paper size / Share /
* Download / Print bar. Each invoice type only supplies its own A4 and receipt layouts.
*/
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
export const ACCENT = "#8b1c24"
export type PaperSize = "A4" | "80mm" | "58mm"
export type ReceiptWidth = Exclude<PaperSize, "A4">
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" },
{ value: "58mm", label: "58mm receipt", hint: "Small 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; }",
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
}
/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */
export const RECEIPT_SHEET_CLASS: Record<ReceiptWidth, string> = {
"80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]",
"58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]",
}
/** Screen chrome for the on-screen A4 page; in print the @page margin does the spacing. */
export const A4_SHEET_CLASS =
"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"
/** Screen chrome for a receipt roll; combine with RECEIPT_SHEET_CLASS[width]. */
export const RECEIPT_SHEET_BASE_CLASS =
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
export const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const
export function money(n: number) {
return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
export function qty(n: number) {
return n.toLocaleString(undefined, { maximumFractionDigits: 3 })
}
/** dd/MM/yyyy, as on the reference invoice. */
export 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()}`
}
/** Section heading with the short accent underline. */
export function SectionTitle({ children }: { children: 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>
)
}
/** Dashed divider used between receipt sections. */
export function ReceiptRule() {
return <div className="my-2 border-t border-dashed border-neutral-500" />
}
export 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>
)
}
/** Terms + footer note lines from the invoice template, blank lines dropped. */
export function templateNotes(termsAndConditions?: string | null, footerNote?: string | null): string[] {
return [...(termsAndConditions ?? "").split("\n"), ...(footerNote ?? "").split("\n")].map((l) => l.trim()).filter(Boolean)
}
const subscribeNoop = () => () => {}
/** True only on the client — the print copy is portalled into document.body. */
function useIsClient() {
return useSyncExternalStore(subscribeNoop, () => true, () => false)
}
/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
const A4_HEIGHT_MM = 297
/**
* Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured
* as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split
* across as many 297mm pages as needed; a receipt is one page as long as the receipt itself.
* Both libraries are loaded on demand.
*/
async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise<Blob> {
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
// The sheet's drop shadow / ring are screen decoration only.
const dataUrl = await toPng(sheet, {
pixelRatio: 2,
backgroundColor: "#ffffff",
style: { boxShadow: "none", margin: "0" },
// A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF.
imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
})
const img = new Image()
img.src = dataUrl
await img.decode()
const widthMm = PAPER_WIDTH_MM[size]
const imgHeightMm = (img.height * widthMm) / img.width
if (size !== "A4") {
const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] })
pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm)
return pdf.output("blob")
}
const pdf = new jsPDF({ unit: "mm", format: "a4" })
// Same image on every page, shifted up one page-height each time; the page box crops the rest.
for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) {
if (page > 0) pdf.addPage()
pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm)
}
return pdf.output("blob")
}
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
/**
* Invoice preview + paper-size bar underneath + Share / Download / 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 InvoicePreviewFrame({
renderSheet,
error,
docNo,
shareTitle,
sharePath,
className,
}: {
/** The invoice laid out for a paper size, or null while it is still loading. */
renderSheet: ((size: PaperSize) => ReactNode) | null
error: string | null
/** Used for the PDF file name. */
docNo: string
shareTitle: string
/** App path of a page showing this invoice — shared/copied where the PDF itself can't be. */
sharePath: string
className?: string
}) {
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
const isClient = useIsClient()
const previewRef = useRef<HTMLDivElement>(null)
const [busy, setBusy] = useState<"download" | "share" | null>(null)
const fileName = `${docNo}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf`
async function makePdf(): Promise<Blob> {
const sheet = previewRef.current?.querySelector<HTMLElement>(".invoice-sheet")
if (!sheet) throw new Error("The invoice is still loading.")
return buildInvoicePdf(sheet, paperSize)
}
async function handleDownload() {
setBusy("download")
try {
saveBlob(await makePdf(), fileName)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setBusy(null)
}
}
// Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge);
// otherwise shares — or, failing that, copies — a link to this invoice's page.
async function handleShare() {
const link = `${window.location.origin}${sharePath}`
setBusy("share")
try {
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], title: shareTitle, text: shareTitle })
} else if (navigator.share) {
await navigator.share({ title: shareTitle, text: shareTitle, url: link })
} else {
await navigator.clipboard.writeText(link)
toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.")
}
} catch (err) {
// Closing the share sheet isn't an error.
if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err))
} finally {
setBusy(null)
}
}
if (error && !renderSheet) {
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 ref={previewRef} className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
{renderSheet ? renderSheet(paperSize) : <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">
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
<Select<PaperSize>
value={paperSize}
onValueChange={(v) => v && setPaperSize(v)}
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
>
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAPER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>
<span className="flex flex-col">
<span className="text-sm font-medium">{s.label}</span>
<span className="text-xs text-muted-foreground">{s.hint}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-2">
<Button size="lg" variant="outline" onClick={handleShare} disabled={!renderSheet || busy !== null}>
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
Share
</Button>
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!renderSheet || busy !== null}>
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
Download
</Button>
<Button size="lg" onClick={() => window.print()} disabled={!renderSheet}>
<Printer className="size-4" />
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
</Button>
</div>
</div>
{isClient &&
renderSheet &&
createPortal(
<div className="invoice-print-root">
<style>{PAGE_RULES[paperSize]}</style>
{renderSheet(paperSize)}
</div>,
document.body
)}
</div>
)
}
@@ -13,8 +13,8 @@ import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor"
interface PurchaseInvoicePaymentDialogProps {
// PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog
@@ -24,23 +24,34 @@ interface PurchaseInvoicePaymentDialogProps {
invoice: PurchaseInvoiceSummary | null
open: boolean
onOpenChange: (open: boolean) => void
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
* it started that way or this dialog auto-posted it first. */
/** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever
* succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */
onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void
/** Called once, after the dialog closes, when this submit cleared the whole balance. */
onFullyPaid?: () => void
}
/** Record a vendor payment against a Purchase Invoice's balance — full or partial, into an
* existing GL cash/bank account. A Draft invoice is posted first (the backend only accepts
* payments against a Posted invoice) so this doubles as "confirm and pay in one step" from a
* list row; an already-Posted invoice skips straight to payment. Modeled on
* SalesInvoicePaymentDialog's "pick an account, submit" shape. */
export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: PurchaseInvoicePaymentDialogProps) {
/** Record a vendor payment against a Purchase Invoice's balance — full or partial, split across
* one or more payment lines (e.g. part cash, part bank), each from an existing GL cash/bank
* account. Same shape as SalesInvoicePaymentDialog: each line is posted as its own payment (the
* backend already allows several installments). A Draft invoice is posted first (the backend only
* accepts payments against a Posted invoice) so this doubles as "confirm and pay in one step" from
* a list row. If a line fails part-way, the lines already recorded are removed from the form and
* the rest stay for a retry. */
export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid, onFullyPaid }: PurchaseInvoicePaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glBankAccountId, setGlBankAccountId] = useState("")
const [amount, setAmount] = useState("")
const [lines, setLines] = useState<PaymentLine[]>([])
const [reference, setReference] = useState("")
const [paymentDate, setPaymentDate] = useState(localToday)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// What this dialog has already done to the invoice in this session — the `invoice` prop isn't
// guaranteed to be refreshed by the parent between a partial failure and the retry.
const [postedHere, setPostedHere] = useState(false)
const [paidHere, setPaidHere] = useState(0)
// Balance as it stood when the dialog opened. The parent pages apply each `onPaid` to the
// invoice they pass in, so subtracting `paidHere` from the live prop would count it twice.
const [openingBalance, setOpeningBalance] = useState(0)
// Reset the form fields for a fresh open (or a different invoice) during render — same
// "adjust state while rendering" pattern as SalesInvoicePaymentDialog, not inside an effect.
@@ -48,10 +59,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
const [resetFor, setResetFor] = useState<string | null>(null)
if (openKey !== null && resetFor !== openKey) {
setResetFor(openKey)
setAmount(invoice!.totals.balanceAmount.toFixed(2))
setGlBankAccountId("")
setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }])
setReference("")
setPaymentDate(localToday())
setErrors({})
setPostedHere(false)
setPaidHere(0)
setOpeningBalance(invoice!.totals.balanceAmount)
}
useEffect(() => {
@@ -61,98 +75,120 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
if (!invoice) return null
const isDraft = invoice.status === "Draft" && !postedHere
const balance = round2(openingBalance - paidHere)
async function submit() {
if (!invoice) return
const nextErrors: Record<string, string> = {}
const amountNum = Number(amount)
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay from"
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
const nextErrors = validatePaymentLines(lines, balance)
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
if (invoice.status === "Draft") {
try {
await purchaseInvoicesApi.post(invoice.purchaseInvoiceId)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
return
}
if (isDraft) {
try {
await purchaseInvoicesApi.post(invoice.purchaseInvoiceId)
setPostedHere(true)
} catch (err) {
toast.error("Could not confirm invoice", errorMessage(err))
setSubmitting(false)
return
}
}
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
amount: amountNum,
glBankAccountId: Number(glBankAccountId),
reference: reference || undefined,
})
const fullyPaid = amountNum >= invoice.totals.balanceAmount
// One payment per line, in order. On a failure, stop: drop the recorded lines from the form and
// keep the rest so the user can fix and retry without double-paying.
const recorded: PurchaseInvoicePayment[] = []
let failure: unknown = null
for (const line of lines) {
try {
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
amount: Number(line.amount),
glBankAccountId: Number(line.glBankAccountId),
reference: reference || undefined,
paymentDate,
})
recorded.push(payment)
onPaid(payment, "Posted")
} catch (err) {
failure = err
break
}
}
setSubmitting(false)
const recordedTotal = round2(recorded.reduce((sum, p) => sum + p.amount, 0))
if (failure === null) {
const journals = recorded.map((p) => p.glJournalNo ?? "—").join(", ")
const fullyPaid = recordedTotal >= balance
toast.success(
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
`${formatAmount(recordedTotal)} posted to the ledger (${journals}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
)
onPaid(payment, "Posted")
onOpenChange(false)
} catch (err) {
toast.error(
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
errorMessage(err)
)
} finally {
setSubmitting(false)
if (fullyPaid) onFullyPaid?.()
return
}
setPaidHere((p) => round2(p + recordedTotal))
setLines((prev) => prev.slice(recorded.length))
toast.error(
recorded.length > 0
? `${recorded.length} of ${lines.length} payment lines recorded (${formatAmount(recordedTotal)}); the rest could not be`
: invoice.status === "Draft"
? "Invoice was confirmed, but the payment could not be recorded"
: "Could not record payment",
errorMessage(failure)
)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
</p>
<FieldGroup>
<Field data-invalid={!!errors.glBankAccountId}>
<FieldLabel htmlFor="pinv-pay-account">Pay from</FieldLabel>
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
<SelectTrigger id="pinv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
</SelectTrigger>
<SelectContent>
{(accounts ?? []).map((a) => (
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
{a.accountName} ({a.accountType})
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field>
<FieldLabel htmlFor="pinv-pay-invoice-no">Invoice no.</FieldLabel>
<Input
id="pinv-pay-invoice-no"
value={invoice.docNo}
readOnly
tabIndex={-1}
className="cursor-not-allowed bg-muted text-muted-foreground"
/>
</Field>
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="pinv-pay-amount">Amount</FieldLabel>
<Input
id="pinv-pay-amount"
type="number"
step="0.01"
min="0.01"
max={invoice.totals.balanceAmount}
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-invalid={!!errors.amount}
/>
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
<Field data-invalid={!!errors.paymentDate}>
<FieldLabel htmlFor="pinv-pay-date" required>Payment date</FieldLabel>
<Input
id="pinv-pay-date"
type="date"
max={localToday()}
value={paymentDate}
onChange={(e) => setPaymentDate(e.target.value)}
aria-invalid={!!errors.paymentDate}
/>
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="pinv-pay-reference">Reference (optional)</FieldLabel>
<Input id="pinv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Field>
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup>
<DialogFooter>
@@ -160,11 +196,7 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting
? "Recording…"
: invoice.status === "Draft"
? "Confirm & record payment"
: "Record payment"}
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
</Button>
</DialogFooter>
</DialogContent>
@@ -0,0 +1,386 @@
"use client"
import { ReactNode, useEffect, useState } from "react"
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
import { vendorsApi } from "@/lib/api/vendors"
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 { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
import {
A4_SHEET_CLASS,
ACCENT,
formatDate,
InvoicePreviewFrame,
money,
PaperSize,
printColorExact,
RECEIPT_SHEET_BASE_CLASS,
RECEIPT_SHEET_CLASS,
ReceiptRow,
ReceiptRule,
ReceiptWidth,
SectionTitle,
templateNotes,
} from "@/components/invoice-template/InvoicePrintKit"
import { CompanyProfile } from "@/types/company-profile"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { Vendor } from "@/types/master-data"
import { PurchaseInvoice, PurchaseInvoicePayment } from "@/types/purchase-invoices"
/*
* Printable purchase invoice (vendor bill) in the same three paper sizes as the sales invoice —
* A4, 80mm and 58mm receipt — sharing its preview frame, PDF export and print plumbing. The
* lines are the GRNs the bill covers; "From" is the vendor and "Bill To" is our company.
*/
/** Everything both layouts render, resolved once. */
interface PurchaseInvoiceDoc {
invoice: PurchaseInvoice
vendor: Vendor | null
payments: PurchaseInvoicePayment[]
company: CompanyProfile | null
template: InvoiceTemplateConfig | null
notes: string[]
showLogo: boolean
}
function A4Sheet({ invoice, vendor, payments, company, template, notes, showLogo }: PurchaseInvoiceDoc) {
const t = invoice.totals
return (
<div className={A4_SHEET_CLASS} style={printColorExact}>
{/* Title + meta */}
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">PURCHASE 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.docNo}</dd>
<dt className="text-xs font-bold text-neutral-900">Vendor Invoice No.</dt>
<dd>{invoice.vendorInvoiceNo}</dd>
<dt className="text-xs font-bold text-neutral-900">Invoice Date</dt>
<dd>{formatDate(invoice.vendorInvoiceDate)}</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 (vendor) / Bill To (us) */}
<div className="grid grid-cols-2 gap-10">
<div>
<SectionTitle>From</SectionTitle>
<div className="leading-snug">
<p className="font-bold text-neutral-900">{invoice.vendorSnapshotName}</p>
{vendor?.code && <p>Vendor code: {vendor.code}</p>}
<div className="mt-2">
{invoice.vendorSnapshotTaxNo && <p>Tax No: {invoice.vendorSnapshotTaxNo}</p>}
{vendor?.terms && <p>Terms: {vendor.terms}</p>}
</div>
</div>
</div>
<div>
<SectionTitle>Bill To</SectionTitle>
{company ? (
<div className="leading-snug">
<p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>
<p>{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}</p>
<p>{[company.city, company.country].filter(Boolean).join(", ")}</p>
<div className="mt-2">
{company.phone && <p>{company.phone}</p>}
{company.email && <p>{company.email}</p>}
{template?.showTaxRegNo && company.taxRegistrationNo && <p>Tax Reg No: {company.taxRegistrationNo}</p>}
</div>
</div>
) : (
<p className="text-neutral-500 print:hidden">Company details aren&apos;t set up yet (Settings → Company Profile).</p>
)}
</div>
</div>
{/* GRN lines */}
<div className="mt-8">
<SectionTitle>Goods Received</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">GRN</th>
<th className="px-2 py-2.5 text-left">Received</th>
<th className="px-2 py-2.5 text-right">Subtotal</th>
<th className="px-2 py-2.5 text-right">VAT</th>
<th className="px-2 py-2.5 text-right">Amount</th>
</tr>
</thead>
<tbody>
{invoice.lines.map((line) => (
<tr key={line.purchaseInvoiceLineId} className="border-b border-neutral-300">
<td className="px-2 py-2.5">{line.grnDocNo}</td>
<td className="px-2 py-2.5">{formatDate(line.grnPostedAt)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.subtotal)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{line.vatTotal > 0 ? money(line.vatTotal) : "—"}</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.vatTotal > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(t.vatTotal)}</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.grandTotal)}</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>
{/* Payments */}
<div className="mt-8 print:break-inside-avoid">
<SectionTitle>Payments</SectionTitle>
{payments.length > 0 ? (
<ul className="text-xs">
{payments.map((p) => (
<li key={p.purchaseInvoicePaymentId} 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>
)}
</div>
</div>
)
}
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
function ReceiptSheet({ invoice, payments, company, template, notes, showLogo, width }: PurchaseInvoiceDoc & { width: ReceiptWidth }) {
const t = invoice.totals
return (
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} 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-sm font-extrabold tracking-[0.15em]">PURCHASE INVOICE</p>
{invoice.status !== "Posted" && <p className="font-bold">*** {invoice.status.toUpperCase()} ***</p>}
</div>
<ReceiptRule />
<ReceiptRow label="Invoice No." value={invoice.docNo} />
<ReceiptRow label="Vendor Inv." value={invoice.vendorInvoiceNo} />
<ReceiptRow label="Date" value={formatDate(invoice.vendorInvoiceDate)} />
<ReceiptRow label="Vendor" value={invoice.vendorSnapshotName} />
<ReceiptRule />
<div className="flex justify-between font-bold">
<span>GRN</span>
<span>Amount</span>
</div>
<ReceiptRule />
<div className="space-y-1.5">
{invoice.lines.map((line) => (
<div key={line.purchaseInvoiceLineId}>
<p className="font-medium">{line.grnDocNo}</p>
<div className="flex justify-between gap-2 tabular-nums">
<span>{formatDate(line.grnPostedAt)}</span>
<span>{money(line.lineTotal)}</span>
</div>
{line.vatTotal > 0 && <p className="text-right tabular-nums">incl. VAT {money(line.vatTotal)}</p>}
</div>
))}
</div>
<ReceiptRule />
<ReceiptRow label="Subtotal" value={money(t.subtotal)} />
{t.vatTotal > 0 && <ReceiptRow label="VAT" value={money(t.vatTotal)} />}
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
<ReceiptRow label="TOTAL" value={money(t.grandTotal)} 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.purchaseInvoicePaymentId} 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>)}
</>
)}
</div>
)
}
/** Loads the invoice plus the optional decoration around it (vendor, payments, company, template).
* Only the invoice is required — any other failure just leaves that block out. */
function usePurchaseInvoiceDoc(purchaseInvoiceId: number, refreshKey = 0) {
const [doc, setDoc] = useState<PurchaseInvoiceDoc | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(purchaseInvoiceId) ? loadError : `Invalid purchase invoice id '${purchaseInvoiceId}'.`
useEffect(() => {
if (!Number.isFinite(purchaseInvoiceId)) return
let cancelled = false
purchaseInvoicesApi
.get(purchaseInvoiceId)
.then(async ({ data: invoice }) => {
const [vendorRes, payments, companyRes, templateRes] = await Promise.all([
vendorsApi.get(invoice.vendorId).catch(() => null),
invoice.status === "Posted" ? purchaseInvoicesApi.listPayments(purchaseInvoiceId).catch(() => []) : Promise.resolve([]),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
if (cancelled) return
const company = companyRes.data
const template = templateRes.data
setDoc({
invoice,
vendor: vendorRes?.data ?? null,
payments,
company,
template,
notes: templateNotes(template?.termsAndConditions, template?.footerNote),
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
})
})
.catch((err) => {
if (!cancelled) setLoadError(errorMessage(err))
})
return () => {
cancelled = true
}
// refreshKey: bumped by the caller after a payment so the sheet shows the new state.
}, [purchaseInvoiceId, refreshKey])
return { doc, error }
}
function Sheet({ size, doc }: { size: PaperSize; doc: PurchaseInvoiceDoc }) {
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
}
/** Purchase invoice preview + paper-size bar underneath + Share / Download / Print. */
export function PurchaseInvoicePrintPreview({
purchaseInvoiceId,
className,
refreshKey,
}: {
purchaseInvoiceId: number
className?: string
refreshKey?: number
}) {
const { doc, error } = usePurchaseInvoiceDoc(purchaseInvoiceId, refreshKey)
return (
<InvoicePreviewFrame
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
error={error}
docNo={doc?.invoice.docNo ?? `purchase-invoice-${purchaseInvoiceId}`}
shareTitle={`Purchase invoice ${doc?.invoice.docNo ?? ""}`.trim()}
sharePath={`/dashboard/procurement/purchase-invoices/${purchaseInvoiceId}`}
className={className}
/>
)
}
/** Popup form of the print preview — same frame as SalesInvoicePrintDialog. Opens on A4 every time. */
export function PurchaseInvoicePrintDialog({
purchaseInvoiceId,
invoiceNo,
open,
onOpenChange,
actions,
refreshKey,
}: {
purchaseInvoiceId: number | null
invoiceNo?: string
open: boolean
onOpenChange: (open: boolean) => void
/** Optional buttons shown in the header (e.g. Record payment). */
actions?: ReactNode
/** Bump to reload the invoice (after a payment, …). */
refreshKey?: number
}) {
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="flex flex-col gap-3 border-b px-5 py-4 pr-12 sm:flex-row sm:items-center sm:justify-between">
<div>
<DialogTitle className="text-lg font-semibold">Purchase invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{purchaseInvoiceId !== null && (
<PurchaseInvoicePrintPreview purchaseInvoiceId={purchaseInvoiceId} refreshKey={refreshKey} className="flex-1" />
)}
</DialogContent>
</Dialog>
)
}
@@ -49,7 +49,10 @@ export function BrandPanel() {
async function handleAdd() {
const nextErrors = validateBrandName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create brand", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
@@ -91,7 +94,10 @@ export function BrandPanel() {
if (!editing) return
const nextErrors = validateBrandName(editName)
setEditErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not update brand", Object.values(nextErrors)[0])
return
}
setEditSubmitting(true)
try {
@@ -116,7 +122,10 @@ export function BrandPanel() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
setName(e.target.value)
setErrors({})
}}
placeholder="New brand name, e.g. Bosch"
className="h-12 flex-1 text-base"
aria-label="New brand name"
@@ -178,11 +187,14 @@ export function BrandPanel() {
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!editErrors.name}>
<FieldLabel htmlFor="brand-edit-name">Name</FieldLabel>
<FieldLabel htmlFor="brand-edit-name" required>Name</FieldLabel>
<Input
id="brand-edit-name"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onChange={(e) => {
setEditName(e.target.value)
setEditErrors({})
}}
aria-invalid={!!editErrors.name}
/>
<FieldError errors={[editErrors.name ? { message: editErrors.name } : undefined]} />
@@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type SortOrder = "asc" | "desc"
type SortKey = "categoryId" | "name" | "status" | "createdAt"
@@ -145,7 +146,10 @@ export function CategoryPanel() {
async function handleSubmit() {
const nextErrors = validateCategoryName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (Object.keys(nextErrors).length > 0) {
toast.error(editing ? "Could not update category" : "Could not create category", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
@@ -219,8 +223,8 @@ export function CategoryPanel() {
</DialogHeader>
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="cat-name">Name</FieldLabel>
<Input id="cat-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} />
<FieldLabel htmlFor="cat-name" required>Name</FieldLabel>
<Input id="cat-name" value={name} onChange={(e) => { setName(e.target.value); setErrors({}) }} placeholder="Fasteners" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
</FieldGroup>
@@ -385,39 +389,45 @@ export function CategoryPanel() {
{/* Subcategories are their own resource now, not a nested tree.
Hidden while the feature is switched off in Product Configuration. */}
{config?.subcategoriesEnabled && (
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
<ActionTooltip label="Manage subcategories">
<Link
href={`/dashboard/products/categories/${c.categoryId}`}
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
aria-label={`Manage subcategories of ${c.name}`}
>
<Network className="size-4" />
</Link>
</ActionTooltip>
)}
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
<ActionTooltip label="Edit">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${c.name}`}
onClick={() => openEditDialog(c)}
>
<Pencil className="size-4" />
</Button>
</ActionTooltip>
{/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */}
<AlertDialog>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
<ActionTooltip label={c.status === "Active" ? "Deactivate" : "Activate"}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className={c.status === "Active" ? "text-destructive hover:bg-destructive/10" : ""}
aria-label={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}`}
disabled={deletingId === c.categoryId}
/>
}
>
{c.status === "Active" ? <Ban className="size-4" /> : <CheckCircle2 className="size-4" />}
</AlertDialogTrigger>
</ActionTooltip>
<AlertDialogContent
variant={c.status === "Active" ? "destructive" : "success"}
title={`${c.status === "Active" ? "Deactivate" : "Activate"} ${c.name}?`}
@@ -453,6 +463,16 @@ export function CategoryPanel() {
)
}
/** Hover label for an icon-only row action; `children` becomes the tooltip trigger. */
function ActionTooltip({ label, children }: { label: string; children: React.ReactElement }) {
return (
<Tooltip>
<TooltipTrigger render={children} />
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}
function SortableHeader({
label,
active,
@@ -53,7 +53,10 @@ export function UomPanel() {
async function handleAdd() {
const nextErrors = validateUomName(name)
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not create UOM", Object.values(nextErrors)[0])
return
}
setSubmitting(true)
try {
@@ -76,7 +79,10 @@ export function UomPanel() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onChange={(e) => {
setName(e.target.value)
setErrors({})
}}
placeholder="New UOM name, e.g. Box-12"
className="h-12 flex-1 text-base"
aria-label="New UOM name"
@@ -1,8 +1,6 @@
"use client"
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"
import { createPortal } from "react-dom"
import { Download, Loader2, Printer, Share2 } from "lucide-react"
import { ReactNode, useEffect, useState } from "react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
@@ -10,55 +8,29 @@ 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
import {
A4_SHEET_CLASS,
ACCENT,
formatDate,
InvoicePreviewFrame,
money,
PaperSize,
printColorExact,
qty,
RECEIPT_SHEET_BASE_CLASS,
RECEIPT_SHEET_CLASS,
ReceiptRow,
ReceiptRule,
ReceiptWidth,
SectionTitle,
templateNotes,
} from "@/components/invoice-template/InvoicePrintKit"
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" | "58mm"
type ReceiptWidth = Exclude<PaperSize, "A4">
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" },
{ value: "58mm", label: "58mm receipt", hint: "Small 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; }",
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
}
/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */
const RECEIPT_SHEET_CLASS: Record<ReceiptWidth, string> = {
"80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]",
"58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]",
}
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
@@ -72,26 +44,11 @@ interface InvoiceDoc {
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}
>
<div className={A4_SHEET_CLASS} style={printColorExact}>
{/* Title + meta */}
<div className="flex items-start justify-between gap-6">
<div>
@@ -262,31 +219,11 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note
)
}
/** 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 / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) {
const t = invoice.totals
return (
<div
className={cn(
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0",
RECEIPT_SHEET_CLASS[width]
)}
style={printColorExact}
>
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} style={printColorExact}>
<div className="text-center">
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
@@ -405,9 +342,7 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
company,
template,
dueDate,
notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")]
.map((l) => l.trim())
.filter(Boolean),
notes: templateNotes(template?.termsAndConditions, template?.footerNote),
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber),
})
@@ -424,186 +359,22 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
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)
}
/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
const A4_HEIGHT_MM = 297
/**
* Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured
* as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split
* across as many 297mm pages as needed; a receipt is one page as long as the receipt itself.
* Both libraries are loaded on demand.
*/
async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise<Blob> {
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
// The sheet's drop shadow / ring are screen decoration only.
const dataUrl = await toPng(sheet, {
pixelRatio: 2,
backgroundColor: "#ffffff",
style: { boxShadow: "none", margin: "0" },
// A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF.
imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
})
const img = new Image()
img.src = dataUrl
await img.decode()
const widthMm = PAPER_WIDTH_MM[size]
const imgHeightMm = (img.height * widthMm) / img.width
if (size !== "A4") {
const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] })
pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm)
return pdf.output("blob")
}
const pdf = new jsPDF({ unit: "mm", format: "a4" })
// Same image on every page, shifted up one page-height each time; the page box crops the rest.
for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) {
if (page > 0) pdf.addPage()
pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm)
}
return pdf.output("blob")
}
function saveBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
}
/**
* 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.
*/
/** Invoice preview + paper-size bar underneath + Share / Download / Print (see InvoicePreviewFrame). */
export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) {
const { doc, error } = useInvoiceDoc(invoiceId, refreshKey)
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
const isClient = useIsClient()
const previewRef = useRef<HTMLDivElement>(null)
const [busy, setBusy] = useState<"download" | "share" | null>(null)
const fileName = `${doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf`
async function makePdf(): Promise<Blob> {
const sheet = previewRef.current?.querySelector<HTMLElement>(".invoice-sheet")
if (!sheet) throw new Error("The invoice is still loading.")
return buildInvoicePdf(sheet, paperSize)
}
async function handleDownload() {
setBusy("download")
try {
saveBlob(await makePdf(), fileName)
} catch (err) {
toast.error("Could not create the PDF", errorMessage(err))
} finally {
setBusy(null)
}
}
// Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge);
// otherwise shares — or, failing that, copies — a link to this invoice's print page.
async function handleShare() {
const title = `Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()
const link = `${window.location.origin}/dashboard/sales/invoices/${invoiceId}/print`
setBusy("share")
try {
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
if (navigator.canShare?.({ files: [file] })) {
await navigator.share({ files: [file], title, text: title })
} else if (navigator.share) {
await navigator.share({ title, text: title, url: link })
} else {
await navigator.clipboard.writeText(link)
toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.")
}
} catch (err) {
// Closing the share sheet isn't an error.
if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err))
} finally {
setBusy(null)
}
}
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 ref={previewRef} 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">
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
<Select<PaperSize>
value={paperSize}
onValueChange={(v) => v && setPaperSize(v)}
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
>
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAPER_SIZES.map((s) => (
<SelectItem key={s.value} value={s.value}>
<span className="flex flex-col">
<span className="text-sm font-medium">{s.label}</span>
<span className="text-xs text-muted-foreground">{s.hint}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap gap-2">
<Button size="lg" variant="outline" onClick={handleShare} disabled={!doc || busy !== null}>
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
Share
</Button>
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!doc || busy !== null}>
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
Download
</Button>
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
<Printer className="size-4" />
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
</Button>
</div>
</div>
{isClient &&
doc &&
createPortal(
<div className="invoice-print-root">
<style>{PAGE_RULES[paperSize]}</style>
<Sheet size={paperSize} doc={doc} />
</div>,
document.body
)}
</div>
<InvoicePreviewFrame
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
error={error}
docNo={doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}
shareTitle={`Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()}
sharePath={`/dashboard/sales/invoices/${invoiceId}/print`}
className={className}
/>
)
}
@@ -35,7 +35,8 @@ type Props = {
labels: string[]
datasets: Array<{
label?: string
data: number[]
/** `null` leaves an empty slot — used to pad the axis so a few bars sit at the start. */
data: (number | null)[]
backgroundColor?: string | string[]
}>
/** Container height in px — defaults to the original 260 so existing callers are unaffected. */
+15 -2
View File
@@ -4,7 +4,13 @@ import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
/** `required` appends a red asterisk to mark a mandatory field. */
function Label({
className,
required,
children,
...props
}: React.ComponentProps<"label"> & { required?: boolean }) {
return (
<label
data-slot="label"
@@ -13,7 +19,14 @@ function Label({ className, ...props }: React.ComponentProps<"label">) {
className
)}
{...props}
/>
>
{children}
{required && (
<span aria-hidden="true" className="-ml-1 text-destructive">
*
</span>
)}
</label>
)
}
@@ -0,0 +1,185 @@
"use client"
import { useEffect, useState } from "react"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { generateVendorCode } from "@/lib/vendor-code"
import { Vendor } from "@/types/master-data"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { toast } from "@/components/ui/toast"
/**
* Edit popup for one vendor, opened from the vendors list. Mount it with `key={vendorId}`
* so each open starts clean. The list rows carry no ETag, so the vendor is re-read here
* for a fresh If-Match before anything can be saved.
*/
export function EditVendorDialog({
vendorId,
existingCodes,
onClose,
onSaved,
}: {
vendorId: number
/** Every loaded vendor code, to de-dupe the regenerated code against (this vendor's own is skipped). */
existingCodes: string[]
onClose: () => void
onSaved: (vendor: Vendor) => void
}) {
const [vendor, setVendor] = useState<Vendor | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [code, setCode] = useState("")
const [name, setName] = useState("")
const [terms, setTerms] = useState("")
const [taxReg, setTaxReg] = useState("")
const [currency, setCurrency] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [saving, setSaving] = useState(false)
function applyVendor(data: Vendor, tag: string | null) {
setVendor(data)
setEtag(tag)
setCode(data.code)
setName(data.name)
setTerms(data.terms ?? "")
setTaxReg(data.taxReg ?? "")
setCurrency(data.currency)
}
function reload() {
return vendorsApi.get(vendorId).then(({ data, etag: tag }) => applyVendor(data, tag))
}
useEffect(() => {
reload().catch((err) => setLoadError(errorMessage(err)))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [vendorId])
/** Drops fields' errors once the user starts editing them. */
function clearErrors(...keys: string[]) {
setErrors((prev) => {
if (!keys.some((k) => prev[k])) return prev
const next = { ...prev }
for (const k of keys) delete next[k]
return next
})
}
function handleNameChange(value: string) {
setName(value)
const otherCodes = existingCodes.filter((c) => c !== vendor?.code)
setCode(value.trim() ? generateVendorCode(value, otherCodes) : "")
// The code is regenerated from the name, so its error is stale too.
clearErrors("name", "code")
}
async function handleSave() {
const nextErrors: Record<string, string> = {}
if (!code.trim()) nextErrors.code = "Vendor code is required"
if (!name.trim()) nextErrors.name = "Vendor name is required"
if (!currency.trim()) nextErrors.currency = "Currency is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) {
toast.error("Could not save vendor", Object.values(nextErrors)[0])
return
}
if (!vendor || !etag) return
setSaving(true)
try {
const result = await vendorsApi.update(
vendorId,
{ code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId },
etag
)
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
onSaved(result.data)
} catch (err) {
if ((err as { code?: string })?.code === "CONCURRENCY_CONFLICT") {
// Someone else saved first: show their version so the user can redo the edit on top of it.
toast.error("Vendor was changed by someone else", "The latest details have been loaded — make your edit again.")
await reload().catch(() => {})
return
}
const fe = fieldErrors(err)
if (fe?.code) setErrors({ code: fe.code })
toast.error("Could not save vendor", errorMessage(err))
} finally {
setSaving(false)
}
}
// Laid out to match the "New vendor" dialog (app/dashboard/vendors/page.tsx).
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader className="items-center text-center">
<DialogTitle>Edit vendor</DialogTitle>
<DialogDescription>Update this supplier record. Its code is regenerated from the name.</DialogDescription>
</DialogHeader>
{loadError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{loadError}</div>
) : !vendor ? (
<div className="flex flex-col gap-5">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : (
<FieldGroup>
<Field data-invalid={!!errors.name}>
<FieldLabel htmlFor="ev-name" required>Name</FieldLabel>
<Input id="ev-name" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} />
<FieldError errors={[errors.name ? { message: errors.name } : undefined]} />
</Field>
<Field data-invalid={!!errors.code}>
<FieldLabel htmlFor="ev-code">Code (auto-generated)</FieldLabel>
<Input id="ev-code" value={code} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="ev-terms">Payment terms (optional)</FieldLabel>
<Input id="ev-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
</Field>
<Field>
<FieldLabel htmlFor="ev-taxreg">Tax registration (optional)</FieldLabel>
<Input id="ev-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
</Field>
<Field data-invalid={!!errors.currency}>
<FieldLabel htmlFor="ev-currency" required>Currency</FieldLabel>
<Input
id="ev-currency"
value={currency}
onChange={(e) => {
setCurrency(e.target.value)
clearErrors("currency")
}}
placeholder="LKR"
maxLength={3}
aria-invalid={!!errors.currency}
/>
<FieldError errors={[errors.currency ? { message: errors.currency } : undefined]} />
</Field>
</FieldGroup>
)}
<div className="flex flex-col-reverse gap-3 pt-2 sm:flex-row sm:justify-center">
<Button variant="outline" className="w-full sm:w-auto sm:min-w-36" onClick={onClose} disabled={saving}>
Cancel
</Button>
<Button className="w-full sm:w-auto sm:min-w-36" onClick={handleSave} disabled={saving || !vendor}>
{saving ? "Saving…" : "Save"}
</Button>
</div>
</DialogContent>
</Dialog>
)
}