feat: add purchase invoice batch print page and related components
- Implemented PurchaseInvoiceBatchPrintPage for printing purchase invoices. - Created InvoicePrintHeader and InvoicePrintFooter components for invoice layout. - Added invoice layout presets for different styles (Classic, Compact, Modern). - Developed PurchaseInvoicePaymentDialog for handling payments against invoices. - Introduced API mocks for company profile and invoice template configuration. - Added validation functions for company profile and purchase invoices. - Defined types for company profile, invoice template configuration, and purchase invoices.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { PurchaseOrderStatus, RequisitionStatus, RfqStatus } from "@/types/procurement"
|
||||
import { PurchaseInvoiceStatus } from "@/types/purchase-invoices"
|
||||
|
||||
// Fixed width (not w-fit) so every status pill is the same size regardless of
|
||||
// label length, same convention as components/receiving/status-badges.tsx.
|
||||
const badgeSize = "h-6 w-32 justify-center px-2.5 py-1 text-sm"
|
||||
|
||||
function requisitionClass(status: RequisitionStatus) {
|
||||
return status === "Draft" ? "bg-warning/10 text-warning border-transparent" : "bg-success/10 text-success border-transparent"
|
||||
}
|
||||
|
||||
export function RequisitionStatusBadge({ status }: { status: RequisitionStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${requisitionClass(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function rfqClass(status: RfqStatus) {
|
||||
return status === "Open" ? "bg-success/10 text-success border-transparent" : "bg-muted text-muted-foreground border-transparent"
|
||||
}
|
||||
|
||||
export function RfqStatusBadge({ status }: { status: RfqStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${rfqClass(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function poClass(status: PurchaseOrderStatus) {
|
||||
if (status === "Draft" || status === "PendingApproval") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Cancelled") return "bg-destructive/10 text-destructive border-transparent"
|
||||
if (status === "Closed") return "bg-muted text-muted-foreground border-transparent"
|
||||
return "bg-success/10 text-success border-transparent" // Approved / PartiallyReceived / FullyReceived
|
||||
}
|
||||
|
||||
export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${poClass(status)}`}>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function purchaseInvoiceClass(status: PurchaseInvoiceStatus) {
|
||||
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Posted") return "bg-success/10 text-success border-transparent"
|
||||
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
|
||||
}
|
||||
|
||||
// "Posted" reads as "Approved" everywhere in the UI, matching SalesInvoiceStatusBadge.
|
||||
const purchaseInvoiceStatusLabel: Record<PurchaseInvoiceStatus, string> = {
|
||||
Draft: "Draft",
|
||||
Posted: "Approved",
|
||||
Cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
export function PurchaseInvoiceStatusBadge({ status }: { status: PurchaseInvoiceStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoiceClass(status)}`}>
|
||||
{purchaseInvoiceStatusLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export type PurchaseInvoicePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
|
||||
|
||||
export function purchaseInvoicePaymentStatus(paidAmount: number, balanceAmount: number): PurchaseInvoicePaymentStatus {
|
||||
if (balanceAmount <= 0) return "Paid"
|
||||
if (paidAmount > 0) return "PartiallyPaid"
|
||||
return "Unpaid"
|
||||
}
|
||||
|
||||
function purchaseInvoicePaymentClass(status: PurchaseInvoicePaymentStatus) {
|
||||
if (status === "Paid") return "bg-success/10 text-success border-transparent"
|
||||
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
|
||||
return "bg-muted text-muted-foreground border-transparent" // Unpaid
|
||||
}
|
||||
|
||||
const purchaseInvoicePaymentLabel: Record<PurchaseInvoicePaymentStatus, string> = {
|
||||
Unpaid: "Unpaid",
|
||||
PartiallyPaid: "Partially paid",
|
||||
Paid: "Paid",
|
||||
}
|
||||
|
||||
export function PurchaseInvoicePaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
|
||||
const status = purchaseInvoicePaymentStatus(paidAmount, balanceAmount)
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoicePaymentClass(status)}`}>
|
||||
{purchaseInvoicePaymentLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useMemo, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, BookOpen, Printer } from "lucide-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 { formatAmount } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { PurchaseInvoice, PurchaseInvoicePayment, PurchaseInvoiceStatus } from "@/types/purchase-invoices"
|
||||
|
||||
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 { toast } from "@/components/ui/toast"
|
||||
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
||||
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
||||
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
|
||||
|
||||
/** "Posted" reads as "approved" everywhere in the UI — matches PurchaseInvoiceStatusBadge's label. */
|
||||
function statusWord(status: PurchaseInvoiceStatus): string {
|
||||
return status === "Posted" ? "approved" : status.toLowerCase()
|
||||
}
|
||||
|
||||
export default function PurchaseInvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const router = useRouter()
|
||||
const resolvedParams = use(params)
|
||||
const purchaseInvoiceId = Number(resolvedParams.id)
|
||||
|
||||
const [invoice, setInvoice] = useState<PurchaseInvoice | null>(null)
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [posting, setPosting] = useState(false)
|
||||
|
||||
const [payments, setPayments] = useState<PurchaseInvoicePayment[]>([])
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(purchaseInvoiceId)) {
|
||||
setError(`Invalid purchase invoice id '${resolvedParams.id}'.`)
|
||||
return
|
||||
}
|
||||
Promise.all([
|
||||
vendorsApi.list({ pageSize: 200 }),
|
||||
purchaseInvoicesApi.get(purchaseInvoiceId),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
.then(([vendorRes, doc, companyRes, templateRes]) => {
|
||||
setVendors(vendorRes.items)
|
||||
setInvoice(doc.data)
|
||||
setCompany(companyRes.data)
|
||||
setTemplate(templateRes.data)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [purchaseInvoiceId, resolvedParams.id])
|
||||
|
||||
// No manual Save/Post/Cancel step: a Draft landed on here is posted immediately and
|
||||
// automatically — this page is only ever meant to be seen in its Posted (read-only) state.
|
||||
const autoPostedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!invoice || invoice.status !== "Draft" || autoPostedRef.current) return
|
||||
autoPostedRef.current = true
|
||||
setPosting(true)
|
||||
setError(null)
|
||||
purchaseInvoicesApi
|
||||
.post(purchaseInvoiceId)
|
||||
.then((posted) => {
|
||||
setInvoice(posted)
|
||||
toast.success("Purchase invoice posted", posted.docNo)
|
||||
router.refresh()
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setPosting(false))
|
||||
}, [invoice, purchaseInvoiceId, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!invoice || invoice.status !== "Posted") {
|
||||
setPayments([])
|
||||
return
|
||||
}
|
||||
purchaseInvoicesApi.listPayments(purchaseInvoiceId).then(setPayments).catch(() => setPayments([]))
|
||||
}, [invoice?.status, purchaseInvoiceId])
|
||||
|
||||
function handlePaid(payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) {
|
||||
setInvoice((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
status: newStatus,
|
||||
totals: { ...prev.totals, paidAmount: prev.totals.paidAmount + payment.amount, balanceAmount: prev.totals.balanceAmount - payment.amount },
|
||||
}
|
||||
: prev
|
||||
)
|
||||
setPayments((prev) => [payment, ...prev])
|
||||
}
|
||||
|
||||
const vendor = useMemo(() => vendors.find((v) => v.vendorId === invoice?.vendorId), [vendors, invoice?.vendorId])
|
||||
|
||||
if (error && !invoice) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!invoice) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const layout = getInvoiceLayoutClasses(template?.templateStyle)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Invoice Details</h1>
|
||||
<p className="text-base text-muted-foreground">{invoice.docNo}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{invoice.status !== "Draft" && (
|
||||
<Link href="/dashboard/ledgers/general-ledger" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
<BookOpen className="size-5" />
|
||||
View in Ledger
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`/print/procurement/purchase-invoices/${invoice.purchaseInvoiceId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
>
|
||||
<Printer className="size-5" />
|
||||
Print
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-destructive">{error}</div>}
|
||||
|
||||
<section className="rounded-2xl border bg-card p-6 shadow-sm">
|
||||
<div className="border-b pb-4">
|
||||
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Purchase Invoice</div>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.docNo}</h2>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<PurchaseInvoiceStatusBadge status={invoice.status} />
|
||||
{invoice.status !== "Draft" && (
|
||||
<PurchaseInvoicePaymentStatusBadge paidAmount={invoice.totals.paidAmount} balanceAmount={invoice.totals.balanceAmount} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Vendor invoice: {invoice.vendorInvoiceNo}</div>
|
||||
<div className="text-sm text-muted-foreground">Date: {new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<InvoicePrintHeader company={company} template={template} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{layout.arrangement === "highlight" && (
|
||||
<div className={`mt-5 ${layout.totalsBanner}`}>
|
||||
<span className={layout.totalsBannerLabel}>Grand total</span>
|
||||
<span className={layout.totalsBannerValue}>{formatAmount(invoice.totals.grandTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 py-5 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Vendor</div>
|
||||
<div className="mt-2 font-semibold text-foreground">{invoice.vendorSnapshotName}</div>
|
||||
{vendor?.code && <div className="text-sm text-muted-foreground">Code: {vendor.code}</div>}
|
||||
{invoice.vendorSnapshotTaxNo ? <div className="text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div>
|
||||
<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">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div>
|
||||
<div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="text-right font-semibold">{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow className={layout.tableHeaderRow}>
|
||||
<TableHead className="h-11 px-4 text-sm">GRN</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Posted</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm text-right">Subtotal</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm text-right">VAT</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm text-right">Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoice.lines.map((line) => (
|
||||
<TableRow key={line.purchaseInvoiceLineId}>
|
||||
<TableCell className="px-4 py-3">
|
||||
<Link href={`/dashboard/receiving/grn/${line.grnId}`} className="font-medium hover:underline">
|
||||
{line.grnDocNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-muted-foreground">{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.subtotal)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right tabular-nums">{formatAmount(line.vatTotal)}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(line.lineTotal)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
|
||||
<div className="mt-6 border-t pt-5">
|
||||
{invoice.status === "Draft" ? (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{posting ? "Posting invoice…" : "Waiting to post…"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-4 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>This invoice is {statusWord(invoice.status)}.</span>
|
||||
{invoice.status === "Posted" ? (
|
||||
<Button size="sm" onClick={() => setPayDialogOpen(true)} disabled={invoice.totals.balanceAmount <= 0}>
|
||||
{invoice.totals.balanceAmount <= 0 ? "Fully paid" : "Record payment"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invoice.status === "Posted" && (
|
||||
<div className="mt-5 flex flex-col gap-2 rounded-2xl border border-border p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
||||
<span className="font-semibold text-foreground">Payment status</span>
|
||||
<span className="text-muted-foreground">
|
||||
Paid {formatAmount(invoice.totals.paidAmount)} of {formatAmount(invoice.totals.grandTotal)} — balance {formatAmount(invoice.totals.balanceAmount)}
|
||||
{invoice.glJournalNo ? ` · GL journal ${invoice.glJournalNo}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{payments.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-9 px-3 text-xs">Date</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs">Account</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs">Reference</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs">GL journal</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payments.map((p) => (
|
||||
<TableRow key={p.purchaseInvoicePaymentId}>
|
||||
<TableCell className="px-3 py-2">{new Date(p.paymentDate).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-2">{p.bankAccountName}</TableCell>
|
||||
<TableCell className="px-3 py-2">{p.reference ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2">{p.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-2 text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<PurchaseInvoicePaymentDialog invoice={invoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Combobox } from "@/components/ui/combobox"
|
||||
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { validatePurchaseInvoiceHeader } from "@/lib/validations/purchase-invoices"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { GrnEligibleForInvoice } from "@/types/purchase-invoices"
|
||||
|
||||
const lkr = new Intl.NumberFormat("en-LK", {
|
||||
style: "currency",
|
||||
currency: "LKR",
|
||||
minimumFractionDigits: 2,
|
||||
})
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function NewPurchaseInvoiceContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const vendorIdFromQuery = searchParams.get("vendorId")
|
||||
const grnIdFromQuery = searchParams.get("grnId")
|
||||
const appliedDeepLinkRef = useRef(false)
|
||||
|
||||
const [vendors, setVendors] = useState<Vendor[]>([])
|
||||
const [vendorId, setVendorId] = useState<number | null>(null)
|
||||
const [vendorInvoiceNo, setVendorInvoiceNo] = useState("")
|
||||
const [vendorInvoiceDate, setVendorInvoiceDate] = useState(todayIso())
|
||||
|
||||
const [eligibleGrns, setEligibleGrns] = useState<GrnEligibleForInvoice[] | null>(null)
|
||||
const [selectedGrnIds, setSelectedGrnIds] = useState<Set<number>>(new Set())
|
||||
const [grnsError, setGrnsError] = useState<string | null>(null)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
vendorsApi
|
||||
.list({ pageSize: 200 })
|
||||
.then((res) => {
|
||||
setVendors(res.items)
|
||||
if (vendorIdFromQuery) {
|
||||
setVendorId(Number(vendorIdFromQuery))
|
||||
} else {
|
||||
setVendorId(res.items[0]?.vendorId ?? null)
|
||||
}
|
||||
})
|
||||
.catch((err) => setSubmitError(errorMessage(err)))
|
||||
.finally(() => setLoading(false))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Bumped to force a refetch when the browser restores this page from its back-forward
|
||||
// cache (e.g. Back button after create() redirected away) — bfcache restores the frozen
|
||||
// JS/React state verbatim without re-running effects, so without this the picker would
|
||||
// keep showing the GRN we just invoiced as still eligible.
|
||||
const [refetchTick, setRefetchTick] = useState(0)
|
||||
useEffect(() => {
|
||||
function onPageShow(e: PageTransitionEvent) {
|
||||
if (e.persisted) setRefetchTick((t) => t + 1)
|
||||
}
|
||||
window.addEventListener("pageshow", onPageShow)
|
||||
return () => window.removeEventListener("pageshow", onPageShow)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!vendorId) {
|
||||
setEligibleGrns(null)
|
||||
setSelectedGrnIds(new Set())
|
||||
return
|
||||
}
|
||||
setGrnsError(null)
|
||||
setEligibleGrns(null)
|
||||
purchaseInvoicesApi
|
||||
.listEligibleGrns(vendorId)
|
||||
.then((grns) => {
|
||||
setEligibleGrns(grns)
|
||||
// Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — pre-check
|
||||
// that GRN once its vendor's eligible list has loaded, but only once per page load
|
||||
// (a bfcache-triggered refetch should re-apply it the same way, since the GRN may
|
||||
// still be eligible if the user came Back without ever submitting).
|
||||
if (grnIdFromQuery && (!appliedDeepLinkRef.current || refetchTick > 0)) {
|
||||
appliedDeepLinkRef.current = true
|
||||
const id = Number(grnIdFromQuery)
|
||||
setSelectedGrnIds(grns.some((g) => g.grnId === id) ? new Set([id]) : new Set())
|
||||
} else if (!grnIdFromQuery) {
|
||||
setSelectedGrnIds(new Set())
|
||||
}
|
||||
})
|
||||
.catch((err) => setGrnsError(errorMessage(err)))
|
||||
}, [vendorId, grnIdFromQuery, refetchTick])
|
||||
|
||||
const vendorOptions = useMemo(
|
||||
() => vendors.map((v) => ({ value: v.vendorId, label: `${v.code} — ${v.name}` })),
|
||||
[vendors]
|
||||
)
|
||||
|
||||
function toggleGrn(grnId: number, checked: boolean) {
|
||||
setSelectedGrnIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) next.add(grnId)
|
||||
else next.delete(grnId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function toggleAll(checked: boolean) {
|
||||
setSelectedGrnIds(checked ? new Set(visibleGrns.map((g) => g.grnId)) : new Set())
|
||||
}
|
||||
|
||||
// Deep-linked from a GRN detail page's "Create Purchase Invoice" prompt — that GRN is the
|
||||
// whole reason this page was opened, so show only it rather than the vendor's full eligible
|
||||
// list (which could pull in unrelated GRNs the user never meant to consolidate here).
|
||||
const deepLinkedGrnId = grnIdFromQuery ? Number(grnIdFromQuery) : null
|
||||
const visibleGrns = useMemo(
|
||||
() => (deepLinkedGrnId === null ? (eligibleGrns ?? []) : (eligibleGrns ?? []).filter((g) => g.grnId === deepLinkedGrnId)),
|
||||
[eligibleGrns, deepLinkedGrnId]
|
||||
)
|
||||
|
||||
const selectedGrns = useMemo(
|
||||
() => (eligibleGrns ?? []).filter((g) => selectedGrnIds.has(g.grnId)),
|
||||
[eligibleGrns, selectedGrnIds]
|
||||
)
|
||||
const subtotal = selectedGrns.reduce((sum, g) => sum + g.subtotal, 0)
|
||||
const vatTotal = selectedGrns.reduce((sum, g) => sum + g.vatTotal, 0)
|
||||
const grandTotal = selectedGrns.reduce((sum, g) => sum + g.grandTotal, 0)
|
||||
|
||||
const allSelected = visibleGrns.length > 0 && visibleGrns.every((g) => selectedGrnIds.has(g.grnId))
|
||||
|
||||
async function submit() {
|
||||
const grnIds = Array.from(selectedGrnIds)
|
||||
const nextErrors = validatePurchaseInvoiceHeader({ vendorId, vendorInvoiceNo, vendorInvoiceDate, grnIds })
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSaving(true)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const created = await purchaseInvoicesApi.create({
|
||||
vendorId: vendorId!,
|
||||
vendorInvoiceNo: vendorInvoiceNo.trim(),
|
||||
vendorInvoiceDate,
|
||||
grnIds,
|
||||
})
|
||||
|
||||
// Deep-linked from a specific GRN — the GRN is already known and confirmed, so there's
|
||||
// nothing left to review before posting; skip the separate Draft step and post right away.
|
||||
if (deepLinkedGrnId !== null) {
|
||||
try {
|
||||
const posted = await purchaseInvoicesApi.post(created.data.purchaseInvoiceId)
|
||||
toast.success("Purchase invoice created and posted", posted.docNo)
|
||||
} catch (postErr) {
|
||||
toast.error("Invoice created, but could not be posted", errorMessage(postErr))
|
||||
}
|
||||
} else {
|
||||
toast.success("Purchase invoice created", created.data.docNo)
|
||||
}
|
||||
|
||||
router.push(`/dashboard/procurement/purchase-invoices/${created.data.purchaseInvoiceId}`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Purchase Invoice</h1>
|
||||
<p className="text-base text-muted-foreground">Pick a vendor, then consolidate one or more of its confirmed, un-invoiced GRNs.</p>
|
||||
</div>
|
||||
|
||||
{submitError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>
|
||||
)}
|
||||
|
||||
{loading && <Skeleton className="h-12 w-full" />}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field data-invalid={!!errors.vendorId}>
|
||||
<FieldLabel>Vendor</FieldLabel>
|
||||
<Combobox
|
||||
items={vendorOptions}
|
||||
value={vendorId}
|
||||
onValueChange={setVendorId}
|
||||
placeholder="Search vendor…"
|
||||
aria-invalid={!!errors.vendorId}
|
||||
/>
|
||||
<FieldError errors={[errors.vendorId ? { message: errors.vendorId } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.vendorInvoiceNo}>
|
||||
<FieldLabel htmlFor="pinv-vendor-no">Vendor invoice no.</FieldLabel>
|
||||
<Input
|
||||
id="pinv-vendor-no"
|
||||
value={vendorInvoiceNo}
|
||||
onChange={(e) => setVendorInvoiceNo(e.target.value)}
|
||||
placeholder="e.g. INV-84213"
|
||||
aria-invalid={!!errors.vendorInvoiceNo}
|
||||
/>
|
||||
<FieldError errors={[errors.vendorInvoiceNo ? { message: errors.vendorInvoiceNo } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.vendorInvoiceDate}>
|
||||
<FieldLabel htmlFor="pinv-vendor-date">Vendor invoice date</FieldLabel>
|
||||
<Input
|
||||
id="pinv-vendor-date"
|
||||
type="date"
|
||||
value={vendorInvoiceDate}
|
||||
max={todayIso()}
|
||||
onChange={(e) => setVendorInvoiceDate(e.target.value)}
|
||||
aria-invalid={!!errors.vendorInvoiceDate}
|
||||
/>
|
||||
<FieldError errors={[errors.vendorInvoiceDate ? { message: errors.vendorInvoiceDate } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-foreground">Eligible GRNs</h2>
|
||||
{errors.grnIds && <span className="text-sm text-destructive">{errors.grnIds}</span>}
|
||||
</div>
|
||||
|
||||
{!vendorId && (
|
||||
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">Select a vendor to see its confirmed, un-invoiced GRNs.</p>
|
||||
)}
|
||||
|
||||
{vendorId && grnsError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{grnsError}</div>
|
||||
)}
|
||||
|
||||
{vendorId && !grnsError && eligibleGrns === null && <Skeleton className="h-32 w-full" />}
|
||||
|
||||
{vendorId && !grnsError && eligibleGrns !== null && visibleGrns.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed p-5 text-base text-muted-foreground">
|
||||
{deepLinkedGrnId !== null
|
||||
? "That GRN is no longer eligible to invoice (already invoiced, or not confirmed)."
|
||||
: "No confirmed, un-invoiced GRNs for this vendor."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{vendorId && !grnsError && eligibleGrns !== null && visibleGrns.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-2xl border">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 w-10 px-3">
|
||||
<Checkbox checked={allSelected} onCheckedChange={(v) => toggleAll(!!v)} aria-label="Select all" />
|
||||
</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">GRN</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm">Posted</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm text-right">Lines</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm text-right">Subtotal</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm text-right">VAT</TableHead>
|
||||
<TableHead className="h-11 px-3 text-sm text-right">Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleGrns.map((g) => (
|
||||
<TableRow key={g.grnId}>
|
||||
<TableCell className="px-3 py-3">
|
||||
<Checkbox
|
||||
checked={selectedGrnIds.has(g.grnId)}
|
||||
onCheckedChange={(v) => toggleGrn(g.grnId, !!v)}
|
||||
aria-label={`Select ${g.docNo}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 font-medium">{g.docNo}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-muted-foreground">{new Date(g.postedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right tabular-nums">{g.lineCount}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.subtotal)}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right tabular-nums">{lkr.format(g.vatTotal)}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right font-semibold tabular-nums">{lkr.format(g.grandTotal)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-x-8 gap-y-1 border-t border-border pt-4 text-base">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm text-muted-foreground">Subtotal</span>
|
||||
<span className="tabular-nums">{lkr.format(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-sm text-muted-foreground">VAT</span>
|
||||
<span className="tabular-nums">{lkr.format(vatTotal)}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-muted-foreground">Grand total</span>
|
||||
<span className="font-semibold tabular-nums">{lkr.format(grandTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Link href="/dashboard/procurement/purchase-invoices" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" onClick={submit} disabled={saving}>
|
||||
{saving ? "Creating…" : deepLinkedGrnId !== null ? "Create & Post Invoice" : "Create Purchase Invoice"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewPurchaseInvoicePage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-12 w-full" />}>
|
||||
<NewPurchaseInvoiceContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, CircleDollarSign, Eye, Filter, Plus, Printer, ReceiptText } from "lucide-react"
|
||||
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog"
|
||||
import { PurchaseInvoicePaymentStatusBadge, PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type StatusFilter = PurchaseInvoiceStatus | "All"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const tabs: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All" },
|
||||
{ value: "Draft", label: "Draft" },
|
||||
{ value: "Posted", label: "Approved" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
export default function PurchaseInvoicesPage() {
|
||||
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [page, setPage] = useState(1)
|
||||
const [confirmingId, setConfirmingId] = useState<number | null>(null)
|
||||
const [payDialogInvoice, setPayDialogInvoice] = useState<PurchaseInvoiceSummary | null>(null)
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status])
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
purchaseInvoicesApi
|
||||
.list({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
|
||||
.then((res) => {
|
||||
setRows(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [page, status])
|
||||
|
||||
async function handleConfirm(row: PurchaseInvoiceSummary) {
|
||||
setConfirmingId(row.purchaseInvoiceId)
|
||||
try {
|
||||
const posted = await purchaseInvoicesApi.post(row.purchaseInvoiceId)
|
||||
setRows((prev) => prev?.map((r) => (r.purchaseInvoiceId === row.purchaseInvoiceId ? { ...r, status: posted.status } : r)) ?? prev)
|
||||
toast.success("Invoice confirmed", posted.docNo)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
} finally {
|
||||
setConfirmingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaid(payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) {
|
||||
setRows(
|
||||
(prev) =>
|
||||
prev?.map((r) =>
|
||||
r.purchaseInvoiceId === payment.purchaseInvoiceId
|
||||
? {
|
||||
...r,
|
||||
status: newStatus,
|
||||
totals: { ...r.totals, paidAmount: r.totals.paidAmount + payment.amount, balanceAmount: r.totals.balanceAmount - payment.amount },
|
||||
}
|
||||
: r
|
||||
) ?? prev
|
||||
)
|
||||
}
|
||||
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
rows?.filter((row) =>
|
||||
`${row.docNo} ${row.vendorInvoiceNo} ${row.vendorSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||
) ?? [],
|
||||
[rows, query]
|
||||
)
|
||||
const hasFilters = status !== "All" || query.length > 0
|
||||
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
|
||||
const vatTotal = visibleRows.reduce((sum, row) => sum + row.totals.vatTotal, 0)
|
||||
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
|
||||
const printHref = `/print/procurement/purchase-invoices?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Purchase Invoices</h1>
|
||||
<p className="text-base text-muted-foreground">Vendor bills consolidating confirmed GRNs, with posting and settlement tracking.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={printHref}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
>
|
||||
<Printer className="size-5" />
|
||||
Print batch
|
||||
</Link>
|
||||
<Link href="/dashboard/procurement/purchase-invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
|
||||
<Plus className="size-5" />
|
||||
New Purchase Invoice
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
|
||||
<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>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
placeholder="Filter by vendor, invoice no. or doc no."
|
||||
className="h-12 w-full lg:max-w-sm"
|
||||
/>
|
||||
<Button variant="outline" size="sm" className="lg:ml-auto">
|
||||
<Filter className="size-4" />
|
||||
Advanced
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
{!error && rows === null && (
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visibleRows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
|
||||
<ReceiptText className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No purchase invoices match your filters." : "No purchase invoices yet."}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visibleRows.length > 0 && (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-4 text-sm">Invoice</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Vendor invoice no.</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">GRNs</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Subtotal</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">VAT</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Total</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-4 text-sm text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows.map((row) => (
|
||||
<TableRow key={row.purchaseInvoiceId} className="hover:bg-muted/40">
|
||||
<TableCell className="px-4 py-3.5 font-medium">
|
||||
<Link href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`} className="hover:underline">
|
||||
{row.docNo}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.vendorInvoiceNo}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">{row.vendorSnapshotName}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.grnCount}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">{row.totals.vatTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
|
||||
<TableCell className="px-4 py-3.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<PurchaseInvoiceStatusBadge status={row.status} />
|
||||
{row.status !== "Draft" && (
|
||||
<PurchaseInvoicePaymentStatusBadge paidAmount={row.totals.paidAmount} balanceAmount={row.totals.balanceAmount} />
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3.5 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={confirmingId === row.purchaseInvoiceId}
|
||||
onClick={() => handleConfirm(row)}
|
||||
>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{confirmingId === row.purchaseInvoiceId ? "Confirming…" : "Confirm"}
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "Cancelled" && row.totals.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPayDialogInvoice(row)
|
||||
setPayDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-invoices/${row.purchaseInvoiceId}`}
|
||||
className={cn(buttonVariants({ variant: "ghost", size: "icon-sm" }))}
|
||||
aria-label={`Show invoice ${row.docNo}`}
|
||||
title="Show invoice"
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-4 py-3">
|
||||
<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">Subtotal</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">VAT</div>
|
||||
<div className="text-base font-semibold tabular-nums">{vatTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-muted/20 px-3 py-2">
|
||||
<div className="text-muted-foreground">Grand total</div>
|
||||
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
|
||||
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PurchaseInvoicePaymentDialog invoice={payDialogInvoice} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,26 +3,27 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { BookOpen, CheckCircle2, CircleDollarSign, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
import { BookOpen, CheckCircle2, FileText, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { uomsApi } from "@/lib/api/uoms"
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { baseUomLabel } from "@/lib/uom-label"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ConfirmGrnResponse, Grn, GrnPayment } from "@/types/grn"
|
||||
import { ConfirmGrnResponse, Grn } from "@/types/grn"
|
||||
import { Bin, ItemListItem, Uom } from "@/types/master-data"
|
||||
import { PurchaseInvoice } from "@/types/purchase-invoices"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
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 { toast } from "@/components/ui/toast"
|
||||
import { GrnPaymentStatusBadge, GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { GrnPaymentDialog } from "@/components/receiving/GrnPaymentDialog"
|
||||
import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges"
|
||||
|
||||
export default function GrnDetailPage() {
|
||||
const params = useParams<{ id: string }>()
|
||||
@@ -38,12 +39,24 @@ export default function GrnDetailPage() {
|
||||
const [confirmResult, setConfirmResult] = useState<ConfirmGrnResponse | null>(null)
|
||||
const [releasingLineId, setReleasingLineId] = useState<number | null>(null)
|
||||
|
||||
const [payments, setPayments] = useState<GrnPayment[]>([])
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
const [linkedInvoice, setLinkedInvoice] = useState<PurchaseInvoice | null>(null)
|
||||
|
||||
// Stable per detail-page-session key so a retried confirm click doesn't double-post.
|
||||
const idempotencyKey = useRef(crypto.randomUUID())
|
||||
|
||||
// Bumped to force a refetch when the browser restores this page from its back-forward
|
||||
// cache (e.g. Back button after "Create Purchase Invoice" redirected away) — bfcache
|
||||
// restores the frozen JS/React state verbatim without re-running effects, so without this
|
||||
// the page would keep showing the pre-invoice GRN state (no linked invoice) after Back.
|
||||
const [refetchTick, setRefetchTick] = useState(0)
|
||||
useEffect(() => {
|
||||
function onPageShow(e: PageTransitionEvent) {
|
||||
if (e.persisted) setRefetchTick((t) => t + 1)
|
||||
}
|
||||
window.addEventListener("pageshow", onPageShow)
|
||||
return () => window.removeEventListener("pageshow", onPageShow)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(grnId)) return
|
||||
grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err)))
|
||||
@@ -53,7 +66,7 @@ export default function GrnDetailPage() {
|
||||
setUoms(uo.items)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [grnId])
|
||||
}, [grnId, refetchTick])
|
||||
|
||||
useEffect(() => {
|
||||
if (!grn) return
|
||||
@@ -61,16 +74,12 @@ export default function GrnDetailPage() {
|
||||
}, [grn?.warehouseId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!grn || grn.status === "Draft") return
|
||||
grnsApi.listPayments(grn.grnId).then(setPayments).catch(() => setPayments([]))
|
||||
}, [grn?.grnId, grn?.status])
|
||||
|
||||
function handlePaid(_updated: Grn, payment: GrnPayment) {
|
||||
setGrn((prev) =>
|
||||
prev ? { ...prev, paidAmount: prev.paidAmount + payment.amount, balanceAmount: prev.balanceAmount - payment.amount } : prev
|
||||
)
|
||||
setPayments((prev) => [payment, ...prev])
|
||||
}
|
||||
if (!grn?.purchaseInvoiceId) return
|
||||
purchaseInvoicesApi
|
||||
.get(grn.purchaseInvoiceId)
|
||||
.then((res) => setLinkedInvoice(res.data))
|
||||
.catch(() => setLinkedInvoice(null))
|
||||
}, [grn?.purchaseInvoiceId])
|
||||
|
||||
function itemFor(itemId: number) {
|
||||
return items.find((i) => i.itemId === itemId)
|
||||
@@ -87,9 +96,7 @@ export default function GrnDetailPage() {
|
||||
try {
|
||||
const result = await grnsApi.confirm(grn.grnId, idempotencyKey.current)
|
||||
setConfirmResult(result)
|
||||
setGrn((prev) =>
|
||||
prev ? { ...prev, status: result.status, glJournalNo: result.glJournalNo, balanceAmount: result.balanceAmount } : prev
|
||||
)
|
||||
setGrn((prev) => (prev ? { ...prev, status: result.status, glJournalNo: result.glJournalNo } : prev))
|
||||
toast.success("GRN confirmed", `${result.createdLayers.length} layer(s) posted to stock. Journal ${result.glJournalNo}.`)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
@@ -140,7 +147,6 @@ export default function GrnDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
{grn.status !== "Draft" && <GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{grn.poId ? `Against PO #${grn.poId}` : "Direct receipt"} — Vendor #{grn.vendorId} — Warehouse #{grn.warehouseId}
|
||||
@@ -150,22 +156,14 @@ export default function GrnDetailPage() {
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{grn.status !== "Draft" && (
|
||||
<>
|
||||
<Link
|
||||
href="/dashboard/ledgers/general-ledger"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
title="View this GRN's postings in the General Ledger report"
|
||||
>
|
||||
<BookOpen className="size-5" />
|
||||
View in Ledger
|
||||
</Link>
|
||||
{grn.balanceAmount > 0 && (
|
||||
<Button size="lg" onClick={() => setPayDialogOpen(true)}>
|
||||
<CircleDollarSign className="size-5" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
<Link
|
||||
href="/dashboard/ledgers/general-ledger"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
|
||||
title="View this GRN's postings in the General Ledger report"
|
||||
>
|
||||
<BookOpen className="size-5" />
|
||||
View in Ledger
|
||||
</Link>
|
||||
)}
|
||||
{grn.status === "Draft" && (
|
||||
<Button size="lg" onClick={handleConfirm} disabled={confirming}>
|
||||
@@ -176,8 +174,6 @@ export default function GrnDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GrnPaymentDialog grn={grn} open={payDialogOpen} onOpenChange={setPayDialogOpen} onPaid={handlePaid} />
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
@@ -314,47 +310,42 @@ export default function GrnDetailPage() {
|
||||
<span className="text-muted-foreground">Document total</span>
|
||||
<span className="font-semibold tabular-nums">{grn.lines.reduce((s, l) => s + l.lineTotal, 0).toFixed(2)}</span>
|
||||
</div>
|
||||
{grn.status !== "Draft" && (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Amount paid</span>
|
||||
<span className="tabular-nums">{formatAmount(grn.paidAmount)}</span>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<span className="text-muted-foreground">Balance due</span>
|
||||
<span className="font-semibold tabular-nums">{formatAmount(grn.balanceAmount)}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{grn.status !== "Draft" && payments.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">Payment History</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reference</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">GL Journal</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{payments.map((p) => (
|
||||
<TableRow key={p.grnPaymentId}>
|
||||
<TableCell className="px-3 py-3.5">{new Date(p.paymentDate).toLocaleString()}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(p.amount)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.bankAccountName}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.reference ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{p.glJournalNo ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{grn.status === "Confirmed" && (
|
||||
<div className="flex flex-col gap-3 rounded-2xl border border-dashed p-5">
|
||||
<h2 className="text-lg font-semibold text-foreground">Purchase Invoice</h2>
|
||||
{grn.purchaseInvoiceId ? (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium text-foreground">{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}</span>
|
||||
{linkedInvoice && <PurchaseInvoiceStatusBadge status={linkedInvoice.status} />}
|
||||
{linkedInvoice && linkedInvoice.status !== "Draft" && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Balance due: {linkedInvoice.totals.balanceAmount.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`}
|
||||
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
View invoice
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-sm text-muted-foreground">This GRN hasn't been added to a purchase invoice yet.</span>
|
||||
<Link
|
||||
href={`/dashboard/procurement/purchase-invoices/new?vendorId=${grn.vendorId}&grnId=${grn.grnId}`}
|
||||
className={cn(buttonVariants({ size: "sm" }))}
|
||||
>
|
||||
<FileText className="size-4" />
|
||||
Create Purchase Invoice
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,64 @@ function newKey() {
|
||||
return `gline-${keySeq}`
|
||||
}
|
||||
|
||||
let warrantySeq = 0
|
||||
/** Auto-generated warranty number, pre-filled into the input so the user can accept or edit it. */
|
||||
function generateWarrantyNumber(): string {
|
||||
warrantySeq += 1
|
||||
const stamp = Date.now().toString(36).toUpperCase()
|
||||
const seq = warrantySeq.toString(36).toUpperCase().padStart(3, "0")
|
||||
return `WTY-${stamp}-${seq}`
|
||||
}
|
||||
|
||||
/** Splits a warranty number into its non-numeric prefix and trailing digit run, e.g. "WTY-000101" → { prefix: "WTY-", num: 101, width: 6 }. */
|
||||
function parseTrailingNumber(s: string): { prefix: string; num: number; width: number } | null {
|
||||
const m = s.trim().match(/^(.*?)(\d+)$/)
|
||||
if (!m) return null
|
||||
return { prefix: m[1], num: parseInt(m[2], 10), width: m[2].length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Splices an "adding number" onto the end of the current number: it keeps everything except
|
||||
* the trailing characters, replacing as many of them as `adding` is long, e.g. current
|
||||
* "WTY-000101" + adding "105" (3 chars) → keep "WTY-000", replace the last 3 with "105" →
|
||||
* "WTY-000105".
|
||||
*/
|
||||
function applyAddingNumber(current: string, adding: string): string {
|
||||
const tail = adding.trim()
|
||||
if (!tail) return current
|
||||
if (tail.length >= current.length) return tail
|
||||
return current.slice(0, current.length - tail.length) + tail
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the sequential run of warranty numbers spanning `from` to `to` (inclusive) — both
|
||||
* must share the same prefix and `to` must not come before `from`. Returns null otherwise
|
||||
* so the caller can surface a validation message.
|
||||
*/
|
||||
function buildWarrantyRange(from: string, to: string): string[] | null {
|
||||
const f = parseTrailingNumber(from)
|
||||
const t = parseTrailingNumber(to)
|
||||
if (!f || !t || f.prefix !== t.prefix) return null
|
||||
const span = t.num - f.num + 1
|
||||
if (span <= 0) return null
|
||||
return Array.from({ length: span }, (_, i) => `${f.prefix}${String(f.num + i).padStart(f.width, "0")}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes a line's warrantyNumbers to match its unit count and auto-fills any blank slot
|
||||
* with a generated number — existing (manually edited) values are left untouched.
|
||||
*/
|
||||
function syncWarrantyNumbers(l: DraftLine, item: ItemListItem | null): string[] {
|
||||
if (item?.warranty !== "Warranty") return l.warrantyNumbers
|
||||
const units = unitCount(l.qty)
|
||||
const next = l.warrantyNumbers.slice(0, units)
|
||||
while (next.length < units) next.push("")
|
||||
for (let i = 0; i < units; i++) {
|
||||
if (!next[i] || next[i].trim() === "") next[i] = generateWarrantyNumber()
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function emptyLine(): DraftLine {
|
||||
return {
|
||||
key: newKey(),
|
||||
@@ -218,8 +276,8 @@ export default function NewGrnPage() {
|
||||
}
|
||||
setWarehouseId((prev) => prev ?? openLines[0].warehouseId)
|
||||
setLines(
|
||||
openLines.map(
|
||||
(l): DraftLine => ({
|
||||
openLines.map((l): DraftLine => {
|
||||
const draft: DraftLine = {
|
||||
key: newKey(),
|
||||
poLineId: l.poLineId,
|
||||
itemId: l.itemId,
|
||||
@@ -231,8 +289,9 @@ export default function NewGrnPage() {
|
||||
vatPct: headerVatPct.trim() || "0",
|
||||
holdStatus: "Available",
|
||||
warrantyNumbers: [],
|
||||
})
|
||||
)
|
||||
}
|
||||
return { ...draft, warrantyNumbers: syncWarrantyNumbers(draft, itemFor(draft.itemId)) }
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
setHeaderError(errorMessage(err))
|
||||
@@ -257,7 +316,13 @@ export default function NewGrnPage() {
|
||||
}
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.key !== key) return l
|
||||
const merged = { ...l, ...patch }
|
||||
return { ...merged, warrantyNumbers: syncWarrantyNumbers(merged, itemFor(merged.itemId)) }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function removeLine(key: string) {
|
||||
@@ -280,6 +345,62 @@ export default function NewGrnPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// Typed "From" number plus an "Adding number" per line — "Replace numbers" keeps From as-is
|
||||
// and splices Adding number onto its tail (see applyAddingNumber) to get To, then fills as
|
||||
// many units as that range spans (from unit 1), leaving any remaining units' numbers untouched.
|
||||
const [warrantyRangeInputs, setWarrantyRangeInputs] = useState<Record<string, { from: string; adding: string }>>({})
|
||||
const [warrantyAddErrors, setWarrantyAddErrors] = useState<Record<string, string>>({})
|
||||
|
||||
function updateWarrantyRangeInput(lineKey: string, field: "from" | "adding", value: string) {
|
||||
setWarrantyRangeInputs((prev) => {
|
||||
const current = prev[lineKey] ?? { from: "", adding: "" }
|
||||
return { ...prev, [lineKey]: { ...current, [field]: value } }
|
||||
})
|
||||
}
|
||||
|
||||
// Fills units 1..(To-From+1) with the typed sequence — only as many units as the range
|
||||
// actually spans. Anything beyond that (the other units' numbers) is left exactly as it
|
||||
// was: nothing gets wiped just because the range doesn't cover the whole line.
|
||||
function replaceWarrantyNumbers(lineKey: string, units: number) {
|
||||
const range = warrantyRangeInputs[lineKey]
|
||||
if (!range?.from.trim() || !range?.adding.trim()) {
|
||||
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" }))
|
||||
return
|
||||
}
|
||||
// "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1
|
||||
// number the same way "adding" splices onto "From", so you never have to retype the prefix.
|
||||
const currentFirst = lines.find((l) => l.key === lineKey)?.warrantyNumbers[0]?.trim()
|
||||
const from = currentFirst ? applyAddingNumber(currentFirst, range.from) : range.from
|
||||
const to = applyAddingNumber(from, range.adding)
|
||||
const seq = buildWarrantyRange(from, to)
|
||||
if (!seq) {
|
||||
setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" }))
|
||||
return
|
||||
}
|
||||
if (seq.length > units) {
|
||||
setWarrantyAddErrors((prev) => ({
|
||||
...prev,
|
||||
[lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`,
|
||||
}))
|
||||
return
|
||||
}
|
||||
setWarrantyAddErrors((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[lineKey]
|
||||
return next
|
||||
})
|
||||
setLines((prev) =>
|
||||
prev.map((l) => {
|
||||
if (l.key !== lineKey) return l
|
||||
const next = [...l.warrantyNumbers]
|
||||
while (next.length < units) next.push("")
|
||||
for (let i = 0; i < seq.length; i++) next[i] = seq[i]
|
||||
return { ...l, warrantyNumbers: next }
|
||||
})
|
||||
)
|
||||
// From/To are left exactly as typed — not cleared — so a typo is easy to fix in place.
|
||||
}
|
||||
|
||||
// Lines whose item is warranty-tracked and carry at least one unit — these are the rows
|
||||
// the "Warranty numbers" tab needs to capture, one input per unit.
|
||||
const warrantyLines = lines
|
||||
@@ -806,17 +927,56 @@ export default function NewGrnPage() {
|
||||
<div className="flex flex-col gap-6">
|
||||
{warrantyLines.map(({ line, item, units }) => {
|
||||
const errors = lineErrors[line.key] ?? {}
|
||||
const range = warrantyRangeInputs[line.key] ?? { from: "", adding: "" }
|
||||
const addError = warrantyAddErrors[line.key]
|
||||
return (
|
||||
<div key={line.key} className="flex flex-col gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-2 rounded-lg border border-dashed border-input p-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">From</Label>
|
||||
<Input
|
||||
value={range.from}
|
||||
onChange={(e) => updateWarrantyRangeInput(line.key, "from", e.target.value)}
|
||||
placeholder="e.g. 101 or WTY-000101"
|
||||
className="h-9 w-40 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label className="text-xs text-muted-foreground">Adding number</Label>
|
||||
<Input
|
||||
value={range.adding}
|
||||
onChange={(e) => updateWarrantyRangeInput(line.key, "adding", e.target.value)}
|
||||
placeholder="e.g. 120"
|
||||
className="h-9 w-40 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => replaceWarrantyNumbers(line.key, units)}
|
||||
>
|
||||
Replace numbers
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Type just the trailing digits — From and Adding number each replace that many
|
||||
characters off the end of unit 1's current number. Fills starting from unit 1
|
||||
— units beyond the range keep their number.
|
||||
</span>
|
||||
{addError && <FieldError errors={[{ message: addError }]} />}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-10 w-20 px-3 text-sm">Unit</TableHead>
|
||||
<TableHead className="h-10 px-3 text-sm">Warranty number</TableHead>
|
||||
<TableHead className="h-10 px-3 text-sm">
|
||||
Warranty number
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">(auto-generated — edit if needed)</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { ChevronLeft, ChevronRight, Eye, PackageSearch, Plus, Search } from "luc
|
||||
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { GrnStatus, GrnSummary } from "@/types/grn"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -23,7 +22,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import { GrnPaymentStatusBadge, GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
import { GrnStatusBadge } from "@/components/receiving/status-badges"
|
||||
|
||||
type StatusFilter = GrnStatus | "All"
|
||||
|
||||
@@ -175,8 +174,7 @@ export default function GrnListPage() {
|
||||
<TableHead className="h-12 px-3 text-sm">Vendor</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm text-right">Balance</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Payment</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Invoice</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -199,14 +197,15 @@ export default function GrnListPage() {
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<GrnStatusBadge status={grn.status} />
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">
|
||||
{grn.status === "Draft" ? "—" : formatAmount(grn.balanceAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
{grn.status === "Draft" ? (
|
||||
{grn.status !== "Confirmed" ? (
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
) : grn.purchaseInvoiceId ? (
|
||||
<Link href={`/dashboard/procurement/purchase-invoices/${grn.purchaseInvoiceId}`} className="text-sm hover:underline">
|
||||
{grn.purchaseInvoiceNo ?? `#${grn.purchaseInvoiceId}`}
|
||||
</Link>
|
||||
) : (
|
||||
<GrnPaymentStatusBadge paidAmount={grn.paidAmount} balanceAmount={grn.balanceAmount} />
|
||||
<span className="text-sm text-muted-foreground">Not invoiced</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{new Date(grn.createdAt).toLocaleString()}</TableCell>
|
||||
|
||||
@@ -11,17 +11,24 @@ 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 { 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 { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { CreateSalesInvoiceLineRequest, SalesInvoice, SalesInvoicePayment, SalesInvoicePostingCheck, SalesInvoiceStatus, SalesInvoiceType } from "@/types/sales"
|
||||
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 { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/sales/status-badges"
|
||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
||||
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
|
||||
|
||||
type Line = CreateSalesInvoiceLineRequest & { key: string }
|
||||
|
||||
@@ -63,6 +70,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
|
||||
const [customerId, setCustomerId] = useState<number | null>(null)
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
@@ -87,13 +96,17 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
salesApi.getInvoice(invoiceId),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
.then(([customerRes, itemRes, uomRes, warehouseRes, doc]) => {
|
||||
.then(([customerRes, itemRes, uomRes, warehouseRes, doc, companyRes, templateRes]) => {
|
||||
setCustomers(customerRes.items)
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
setWarehouses(warehouseRes.items)
|
||||
setInvoice(doc.data)
|
||||
setCompany(companyRes.data)
|
||||
setTemplate(templateRes.data)
|
||||
setEtag(doc.etag)
|
||||
setCustomerId(doc.data.customerId)
|
||||
setWarehouseId(doc.data.warehouseId)
|
||||
@@ -274,6 +287,8 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice data is loading or unavailable."}</div>
|
||||
}
|
||||
|
||||
const layout = getInvoiceLayoutClasses(template?.templateStyle)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-3 print:hidden">
|
||||
@@ -301,7 +316,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
|
||||
<section className="bg-white print:bg-white">
|
||||
<div className="border-b pb-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className={`flex flex-col gap-3 md:items-start md:justify-between ${layout.arrangement === "highlight" ? "md:flex-row-reverse" : "md:flex-row"}`}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Invoice</div>
|
||||
<h2 className="mt-2 text-3xl font-semibold text-foreground">{invoice.invoiceNo}</h2>
|
||||
@@ -314,14 +329,18 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<span>Date: {new Date(invoice.invoiceDate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm md:text-right">
|
||||
<div className="font-semibold text-foreground">ERP Core Trading</div>
|
||||
<div className="text-muted-foreground">Company details are not configured for this invoice view.</div>
|
||||
</div>
|
||||
<InvoicePrintHeader company={company} template={template} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 py-5 md:grid-cols-3">
|
||||
{layout.arrangement === "highlight" && (
|
||||
<div className={`mt-5 ${layout.totalsBanner}`}>
|
||||
<span className={layout.totalsBannerLabel}>Net payable</span>
|
||||
<span className={layout.totalsBannerValue}>{money.format(invoice.totals.netPayable)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`grid gap-6 py-5 ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"}`}>
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bill To</div>
|
||||
<div className="mt-2 font-semibold text-foreground">{invoice.customerSnapshotName}</div>
|
||||
@@ -334,26 +353,28 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
<div className="mt-2 font-semibold text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||
<div className="text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||
</div>
|
||||
<div>
|
||||
<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">{money.format(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">Discount</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div>
|
||||
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
||||
<div className="text-muted-foreground">Tax</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
||||
<div className="text-muted-foreground">Net payable</div>
|
||||
<div className="text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div>
|
||||
<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">{money.format(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">Discount</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.discountTotal)}</div>
|
||||
<div className="text-muted-foreground">Free qty</div>
|
||||
<div className="text-right font-medium">{freeQtyTotal.toFixed(0)}</div>
|
||||
<div className="text-muted-foreground">Tax</div>
|
||||
<div className="text-right font-medium">{money.format(invoice.totals.taxTotal)}</div>
|
||||
<div className="text-muted-foreground">Net payable</div>
|
||||
<div className="text-right font-semibold">{money.format(invoice.totals.netPayable)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border-y">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-secondary/60 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<thead className={`text-[11px] uppercase tracking-wide text-muted-foreground ${layout.tableHeaderRow || "bg-secondary/60"}`}>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left font-medium">Item</th>
|
||||
<th className="px-4 py-2 text-left font-medium">UOM</th>
|
||||
@@ -446,10 +467,7 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t pt-5">
|
||||
<div className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Notes</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Standard invoice template view.</p>
|
||||
</div>
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
|
||||
<div className="mt-6 border-t pt-5 print:hidden">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
|
||||
@@ -1,22 +1,290 @@
|
||||
import Link from "next/link"
|
||||
import { Building2 } from "lucide-react"
|
||||
"use client"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Building2, Landmark, Trash2, Upload } from "lucide-react"
|
||||
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { validateCompanyProfile } from "@/lib/validations/company-profile"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
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"
|
||||
|
||||
const blank = {
|
||||
legalName: "",
|
||||
tradeName: "",
|
||||
taxRegistrationNo: "",
|
||||
vatRegistrationNo: "",
|
||||
addressLine1: "",
|
||||
addressLine2: "",
|
||||
city: "",
|
||||
country: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
bankName: "",
|
||||
bankBranch: "",
|
||||
accountName: "",
|
||||
accountNumber: "",
|
||||
swiftCode: "",
|
||||
}
|
||||
|
||||
export default function CompanyProfilePage() {
|
||||
const [profile, setProfile] = useState<CompanyProfile | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [uploadingLogo, setUploadingLogo] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [form, setForm] = useState(blank)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
companyProfileApi
|
||||
.get()
|
||||
.then((res) => {
|
||||
setProfile(res.data)
|
||||
setEtag(res.etag)
|
||||
setForm({
|
||||
legalName: res.data.legalName,
|
||||
tradeName: res.data.tradeName,
|
||||
taxRegistrationNo: res.data.taxRegistrationNo ?? "",
|
||||
vatRegistrationNo: res.data.vatRegistrationNo ?? "",
|
||||
addressLine1: res.data.addressLine1,
|
||||
addressLine2: res.data.addressLine2 ?? "",
|
||||
city: res.data.city,
|
||||
country: res.data.country,
|
||||
phone: res.data.phone,
|
||||
email: res.data.email,
|
||||
bankName: res.data.bankName ?? "",
|
||||
bankBranch: res.data.bankBranch ?? "",
|
||||
accountName: res.data.accountName ?? "",
|
||||
accountNumber: res.data.accountNumber ?? "",
|
||||
swiftCode: res.data.swiftCode ?? "",
|
||||
})
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
function set<K extends keyof typeof blank>(field: K, value: string) {
|
||||
setForm((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!etag) return
|
||||
const nextErrors = validateCompanyProfile(form)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await companyProfileApi.update(
|
||||
{
|
||||
legalName: form.legalName.trim(),
|
||||
tradeName: form.tradeName.trim(),
|
||||
taxRegistrationNo: form.taxRegistrationNo.trim() || null,
|
||||
vatRegistrationNo: form.vatRegistrationNo.trim() || null,
|
||||
addressLine1: form.addressLine1.trim(),
|
||||
addressLine2: form.addressLine2.trim() || null,
|
||||
city: form.city.trim(),
|
||||
country: form.country.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
bankName: form.bankName.trim() || null,
|
||||
bankBranch: form.bankBranch.trim() || null,
|
||||
accountName: form.accountName.trim() || null,
|
||||
accountNumber: form.accountNumber.trim() || null,
|
||||
swiftCode: form.swiftCode.trim() || null,
|
||||
},
|
||||
etag
|
||||
)
|
||||
setProfile(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Company profile saved")
|
||||
} catch (err) {
|
||||
toast.error("Could not save company profile", errorMessage(err))
|
||||
load()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogoPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
e.target.value = ""
|
||||
if (!file) return
|
||||
setUploadingLogo(true)
|
||||
try {
|
||||
const res = await companyProfileApi.uploadLogo(file)
|
||||
setProfile(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Logo uploaded")
|
||||
} catch (err) {
|
||||
toast.error("Could not upload logo", errorMessage(err))
|
||||
} finally {
|
||||
setUploadingLogo(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLogo() {
|
||||
setUploadingLogo(true)
|
||||
try {
|
||||
const res = await companyProfileApi.deleteLogo()
|
||||
setProfile(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Logo removed")
|
||||
} catch (err) {
|
||||
toast.error("Could not remove logo", errorMessage(err))
|
||||
} finally {
|
||||
setUploadingLogo(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl border p-12 text-center">
|
||||
<Building2 className="size-10 text-muted-foreground" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Company Profile</h1>
|
||||
<p className="text-base text-muted-foreground">This feature is not available yet.</p>
|
||||
</div>
|
||||
<Link href="/dashboard/settings" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Back to Settings
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Company Profile</h1>
|
||||
<p className="text-base text-muted-foreground">Legal identity, contact, and bank details used on printed invoices.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && !profile && <Skeleton className="h-96 w-full" />}
|
||||
|
||||
{!error && profile && (
|
||||
<div className="flex flex-col gap-6 rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Identity & contact</h2>
|
||||
</div>
|
||||
<div className="-mt-4 border-t" />
|
||||
|
||||
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.legalName}>
|
||||
<FieldLabel htmlFor="cp-legal-name">Legal name</FieldLabel>
|
||||
<Input id="cp-legal-name" value={form.legalName} onChange={(e) => set("legalName", e.target.value)} aria-invalid={!!errors.legalName} />
|
||||
<FieldError errors={[errors.legalName ? { message: errors.legalName } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-trade-name">Trade name</FieldLabel>
|
||||
<Input id="cp-trade-name" value={form.tradeName} onChange={(e) => set("tradeName", e.target.value)} placeholder="Shown on invoices if set" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-tax-reg">Tax registration no.</FieldLabel>
|
||||
<Input id="cp-tax-reg" value={form.taxRegistrationNo} onChange={(e) => set("taxRegistrationNo", e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-vat-reg">VAT registration no.</FieldLabel>
|
||||
<Input id="cp-vat-reg" value={form.vatRegistrationNo} onChange={(e) => set("vatRegistrationNo", e.target.value)} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.addressLine1}>
|
||||
<FieldLabel htmlFor="cp-address1">Address line 1</FieldLabel>
|
||||
<Input id="cp-address1" value={form.addressLine1} onChange={(e) => set("addressLine1", e.target.value)} aria-invalid={!!errors.addressLine1} />
|
||||
<FieldError errors={[errors.addressLine1 ? { message: errors.addressLine1 } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-address2">Address line 2</FieldLabel>
|
||||
<Input id="cp-address2" value={form.addressLine2} onChange={(e) => set("addressLine2", e.target.value)} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.city}>
|
||||
<FieldLabel htmlFor="cp-city">City</FieldLabel>
|
||||
<Input id="cp-city" value={form.city} onChange={(e) => set("city", e.target.value)} aria-invalid={!!errors.city} />
|
||||
<FieldError errors={[errors.city ? { message: errors.city } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.country}>
|
||||
<FieldLabel htmlFor="cp-country">Country</FieldLabel>
|
||||
<Input id="cp-country" value={form.country} onChange={(e) => set("country", e.target.value)} aria-invalid={!!errors.country} />
|
||||
<FieldError errors={[errors.country ? { message: errors.country } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.phone}>
|
||||
<FieldLabel htmlFor="cp-phone">Phone</FieldLabel>
|
||||
<Input id="cp-phone" value={form.phone} onChange={(e) => set("phone", e.target.value)} aria-invalid={!!errors.phone} />
|
||||
<FieldError errors={[errors.phone ? { message: errors.phone } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.email}>
|
||||
<FieldLabel htmlFor="cp-email">Email</FieldLabel>
|
||||
<Input id="cp-email" type="email" value={form.email} onChange={(e) => set("email", e.target.value)} aria-invalid={!!errors.email} />
|
||||
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Landmark className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Bank details</h2>
|
||||
</div>
|
||||
<div className="-mt-4 border-t" />
|
||||
|
||||
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-bank-name">Bank name</FieldLabel>
|
||||
<Input id="cp-bank-name" value={form.bankName} onChange={(e) => set("bankName", e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-bank-branch">Branch</FieldLabel>
|
||||
<Input id="cp-bank-branch" value={form.bankBranch} onChange={(e) => set("bankBranch", e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-account-name">Account name</FieldLabel>
|
||||
<Input id="cp-account-name" value={form.accountName} onChange={(e) => set("accountName", e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-account-number">Account number</FieldLabel>
|
||||
<Input id="cp-account-number" value={form.accountNumber} onChange={(e) => set("accountNumber", e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-swift">SWIFT code</FieldLabel>
|
||||
<Input id="cp-swift" value={form.swiftCode} onChange={(e) => set("swiftCode", e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<h2 className="text-lg font-semibold text-foreground">Logo</h2>
|
||||
</div>
|
||||
<div className="-mt-4 border-t" />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex size-20 items-center justify-center overflow-hidden rounded-xl border bg-muted/20">
|
||||
{profile.logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- small settings preview, not worth next/image config
|
||||
<img src={profile.logoUrl} alt={profile.legalName} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">No logo</span>
|
||||
)}
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleLogoPick} />
|
||||
<Button type="button" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={uploadingLogo}>
|
||||
<Upload className="size-4" />
|
||||
{uploadingLogo ? "Uploading…" : "Upload logo"}
|
||||
</Button>
|
||||
{profile.logoUrl && (
|
||||
<Button type="button" variant="ghost" onClick={removeLogo} disabled={uploadingLogo}>
|
||||
<Trash2 className="size-4" />
|
||||
Remove logo
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
{profile.updatedAt && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last changed {new Date(profile.updatedAt).toLocaleString()}
|
||||
{profile.updatedBy ? ` by user #${profile.updatedBy}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
<Button type="button" onClick={save} disabled={saving} className="ml-auto">
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link"
|
||||
import { Building2, LayoutTemplate, SlidersHorizontal } from "lucide-react"
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Product Configuration",
|
||||
href: "/dashboard/products/settings",
|
||||
icon: SlidersHorizontal,
|
||||
description: "Configure product and master-data options",
|
||||
},
|
||||
{
|
||||
title: "Invoice Template",
|
||||
href: "/dashboard/settings/invoice-template",
|
||||
icon: LayoutTemplate,
|
||||
description: "Logo, footer, terms, and template style for printed invoices",
|
||||
},
|
||||
{
|
||||
title: "Company Profile",
|
||||
href: "/dashboard/settings/company-profile",
|
||||
icon: Building2,
|
||||
description: "Invoice header, tax, and bank details",
|
||||
},
|
||||
]
|
||||
|
||||
export default function ConfigurationPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Configuration</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Product options, invoice presentation, and company details used across the ERP.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{cards.map((card) => (
|
||||
<Link
|
||||
key={card.href}
|
||||
href={card.href}
|
||||
className="flex items-center gap-4 rounded-2xl border bg-card p-5 shadow-sm ring-1 ring-foreground/5 transition-colors hover:bg-muted"
|
||||
>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10">
|
||||
<card.icon className="size-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-foreground">{card.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{card.description}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { LayoutTemplate } from "lucide-react"
|
||||
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { InvoiceTemplateConfig, InvoiceTemplateStyle, LogoPlacement } from "@/types/invoice-template"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
||||
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
|
||||
|
||||
/**
|
||||
* Invoice Template Config — a singleton settings row shared by both the Sales Invoice and
|
||||
* Purchase Invoice print pages. Unlike Product Configuration's per-toggle autosave, this page
|
||||
* holds two free-text fields (footer note, terms & conditions) that must not PUT on every
|
||||
* keystroke, so it's an explicit-Save form instead.
|
||||
*/
|
||||
export default function InvoiceTemplatePage() {
|
||||
const [config, setConfig] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const [templateStyle, setTemplateStyle] = useState<InvoiceTemplateStyle>("Classic")
|
||||
const [logoPlacement, setLogoPlacement] = useState<LogoPlacement>("Left")
|
||||
const [showBankDetails, setShowBankDetails] = useState(true)
|
||||
const [showTaxRegNo, setShowTaxRegNo] = useState(true)
|
||||
const [footerNote, setFooterNote] = useState("")
|
||||
const [termsAndConditions, setTermsAndConditions] = useState("")
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
Promise.all([invoiceTemplateConfigApi.get(), companyProfileApi.get().catch(() => ({ data: null, etag: null }))])
|
||||
.then(([configRes, companyRes]) => {
|
||||
setConfig(configRes.data)
|
||||
setEtag(configRes.etag)
|
||||
setCompany(companyRes.data)
|
||||
setTemplateStyle(configRes.data.templateStyle)
|
||||
setLogoPlacement(configRes.data.logoPlacement)
|
||||
setShowBankDetails(configRes.data.showBankDetails)
|
||||
setShowTaxRegNo(configRes.data.showTaxRegNo)
|
||||
setFooterNote(configRes.data.footerNote)
|
||||
setTermsAndConditions(configRes.data.termsAndConditions)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
async function save() {
|
||||
if (!etag) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await invoiceTemplateConfigApi.update(
|
||||
{ templateStyle, logoPlacement, showBankDetails, showTaxRegNo, footerNote, termsAndConditions },
|
||||
etag
|
||||
)
|
||||
setConfig(res.data)
|
||||
setEtag(res.etag)
|
||||
toast.success("Invoice template saved")
|
||||
} catch (err) {
|
||||
toast.error("Could not save invoice template", errorMessage(err))
|
||||
load()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const previewConfig: InvoiceTemplateConfig = {
|
||||
templateStyle,
|
||||
logoPlacement,
|
||||
showBankDetails,
|
||||
showTaxRegNo,
|
||||
footerNote,
|
||||
termsAndConditions,
|
||||
updatedAt: config?.updatedAt ?? null,
|
||||
updatedBy: config?.updatedBy ?? null,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Invoice Template</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Logo placement, footer, terms, and template style used by both Sales and Purchase invoice print views.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && !config && <Skeleton className="h-96 w-full" />}
|
||||
|
||||
{!error && config && (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 rounded-xl border p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutTemplate className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Layout</h2>
|
||||
</div>
|
||||
<div className="border-t" />
|
||||
|
||||
<FieldGroup className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel>Template style</FieldLabel>
|
||||
<Select<InvoiceTemplateStyle> value={templateStyle} onValueChange={(v) => v && setTemplateStyle(v)}>
|
||||
<SelectTrigger className="w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Classic" className="text-base">Classic</SelectItem>
|
||||
<SelectItem value="Compact" className="text-base">Compact</SelectItem>
|
||||
<SelectItem value="Modern" className="text-base">Modern</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Logo placement</FieldLabel>
|
||||
<Select<LogoPlacement> value={logoPlacement} onValueChange={(v) => v && setLogoPlacement(v)}>
|
||||
<SelectTrigger className="w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Left" className="text-base">Left</SelectItem>
|
||||
<SelectItem value="Center" className="text-base">Center</SelectItem>
|
||||
<SelectItem value="Right" className="text-base">Right</SelectItem>
|
||||
<SelectItem value="None" className="text-base">None</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="grid grid-cols-1 gap-x-10 md:grid-cols-2">
|
||||
<div className="flex items-start gap-4 py-4">
|
||||
<Switch checked={showBankDetails} onCheckedChange={setShowBankDetails} className="mt-1 shrink-0" aria-label="Show bank details" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Show bank details</span>
|
||||
<span className="text-sm text-muted-foreground">Prints the company's bank name, account and SWIFT code on invoices.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-4 py-4">
|
||||
<Switch checked={showTaxRegNo} onCheckedChange={setShowTaxRegNo} className="mt-1 shrink-0" aria-label="Show tax registration no." />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base font-medium text-foreground">Show tax registration no.</span>
|
||||
<span className="text-sm text-muted-foreground">Prints the company's tax registration number in the invoice header.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FieldGroup className="grid grid-cols-1 gap-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tmpl-footer-note">Footer note</FieldLabel>
|
||||
<Textarea id="tmpl-footer-note" value={footerNote} onChange={(e) => setFooterNote(e.target.value)} placeholder="e.g. Thank you for your business." />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="tmpl-terms">Terms & conditions</FieldLabel>
|
||||
<Textarea id="tmpl-terms" value={termsAndConditions} onChange={(e) => setTermsAndConditions(e.target.value)} placeholder="e.g. Payment due within 30 days." />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
{config.updatedAt && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Last changed {new Date(config.updatedAt).toLocaleString()}
|
||||
{config.updatedBy ? ` by user #${config.updatedBy}` : ""}.
|
||||
</p>
|
||||
)}
|
||||
<Button type="button" onClick={save} disabled={saving} className="ml-auto">
|
||||
{saving ? "Saving…" : "Save changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-dashed p-6">
|
||||
<span className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">Live preview</span>
|
||||
{(() => {
|
||||
const layout = getInvoiceLayoutClasses(templateStyle)
|
||||
const titleBlock = (
|
||||
<div>
|
||||
<div className={layout.eyebrow}>Sales Invoice</div>
|
||||
<h2 className={layout.title}>INV-0001</h2>
|
||||
<div className={layout.meta}>Status: Posted · Type: Standard</div>
|
||||
</div>
|
||||
)
|
||||
const companyBlock = <InvoicePrintHeader company={company} template={previewConfig} />
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border bg-white ${layout.sheet}`}>
|
||||
{layout.arrangement === "compact" ? (
|
||||
<div className={`flex flex-wrap items-start justify-between ${layout.header}`}>
|
||||
{titleBlock}
|
||||
{companyBlock}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid md:grid-cols-[1.4fr_1fr] ${layout.header}`}>
|
||||
{layout.arrangement === "highlight" ? companyBlock : titleBlock}
|
||||
{layout.arrangement === "highlight" ? titleBlock : companyBlock}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layout.arrangement === "highlight" && (
|
||||
<div className={layout.totalsBanner}>
|
||||
<span className={layout.totalsBannerLabel}>Net payable</span>
|
||||
<span className={layout.totalsBannerValue}>12,450.00</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layout.arrangement === "compact" ? (
|
||||
<div className={`flex flex-wrap items-center text-sm ${layout.infoGrid}`}>
|
||||
<div><span className="text-muted-foreground">Customer: </span><span className="font-medium text-foreground">Sample Customer Ltd</span></div>
|
||||
<div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>12,450.00</span></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid grid-cols-2 ${layout.infoGrid}`}>
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>Customer</div>
|
||||
<div className={layout.cardValue}>Sample Customer Ltd</div>
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>Totals</div>
|
||||
<div className="mt-2 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Net payable</span>
|
||||
<span className={layout.totalsHighlight}>12,450.00</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={layout.tableWrap}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className={layout.tableHeaderRow}>
|
||||
<tr>
|
||||
<th className="p-2 text-left font-semibold">Item</th>
|
||||
<th className="p-2 text-right font-semibold">Qty</th>
|
||||
<th className="p-2 text-right font-semibold">Line total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-t">
|
||||
<td className="p-2">Sample product</td>
|
||||
<td className="p-2 text-right">2.00</td>
|
||||
<td className="p-2 text-right">12,450.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<InvoicePrintFooter company={company} template={previewConfig} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { BellRing, Building2, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
|
||||
import { BellRing, ShieldCheck, SlidersHorizontal, Users } from "lucide-react"
|
||||
|
||||
const cards = [
|
||||
{
|
||||
@@ -21,16 +21,10 @@ const cards = [
|
||||
description: "Choose which channels each module can use",
|
||||
},
|
||||
{
|
||||
title: "Product Configuration",
|
||||
href: "/dashboard/products/settings",
|
||||
title: "Configuration",
|
||||
href: "/dashboard/settings/configuration",
|
||||
icon: SlidersHorizontal,
|
||||
description: "Configure product and master-data options",
|
||||
},
|
||||
{
|
||||
title: "Company Profile",
|
||||
href: "/dashboard/settings/company-profile",
|
||||
icon: Building2,
|
||||
description: "Invoice header, tax, and bank details",
|
||||
description: "Product, invoice template, and company profile settings",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -239,6 +239,10 @@
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.invoice-footer {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.invoice-sheet table {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useState } from "react"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
||||
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { PurchaseInvoice } from "@/types/purchase-invoices"
|
||||
|
||||
export default function PurchaseInvoicePrintPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params)
|
||||
const purchaseInvoiceId = Number(resolvedParams.id)
|
||||
const [invoice, setInvoice] = useState<PurchaseInvoice | null>(null)
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(purchaseInvoiceId)) {
|
||||
setError(`Invalid purchase invoice id '${resolvedParams.id}'.`)
|
||||
return
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
purchaseInvoicesApi.get(purchaseInvoiceId),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
.then(([doc, companyRes, templateRes]) => {
|
||||
setInvoice(doc.data)
|
||||
setCompany(companyRes.data)
|
||||
setTemplate(templateRes.data)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [resolvedParams.id, purchaseInvoiceId])
|
||||
|
||||
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 layout = getInvoiceLayoutClasses(template?.templateStyle)
|
||||
|
||||
const titleBlock = (
|
||||
<div>
|
||||
<div className={layout.eyebrow}>Purchase Invoice</div>
|
||||
<h1 className={layout.title}>{invoice.docNo}</h1>
|
||||
<div className={layout.meta}>
|
||||
Vendor invoice: {invoice.vendorInvoiceNo} · Status: {invoice.status}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
const companyBlock = <InvoicePrintHeader company={company} template={template} />
|
||||
|
||||
return (
|
||||
<div className={`invoice-sheet mx-auto flex w-full max-w-5xl flex-col rounded-3xl border bg-white shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none ${layout.sheet}`}>
|
||||
<div className="flex items-center justify-end print:hidden">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{layout.arrangement === "compact" ? (
|
||||
<div className={`invoice-header flex flex-wrap items-start justify-between ${layout.header}`}>
|
||||
{titleBlock}
|
||||
{companyBlock}
|
||||
</div>
|
||||
) : (
|
||||
<div className={`invoice-header grid md:grid-cols-[1.4fr_1fr] ${layout.header}`}>
|
||||
{layout.arrangement === "highlight" ? companyBlock : titleBlock}
|
||||
{layout.arrangement === "highlight" ? titleBlock : companyBlock}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layout.arrangement === "highlight" && (
|
||||
<div className={layout.totalsBanner}>
|
||||
<span className={layout.totalsBannerLabel}>Grand total</span>
|
||||
<span className={layout.totalsBannerValue}>{formatAmount(invoice.totals.grandTotal)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{layout.arrangement === "compact" ? (
|
||||
<div className={`flex flex-wrap items-center text-sm ${layout.infoGrid}`}>
|
||||
<div><span className="text-muted-foreground">Vendor: </span><span className="font-medium text-foreground">{invoice.vendorSnapshotName}</span></div>
|
||||
<div><span className="text-muted-foreground">Vendor invoice date: </span><span className="font-medium text-foreground">{new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</span></div>
|
||||
<div><span className="text-muted-foreground">Grand total: </span><span className={layout.totalsHighlight}>{formatAmount(invoice.totals.grandTotal)}</span></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"} ${layout.infoGrid}`}>
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>Vendor</div>
|
||||
<div className={layout.cardValue}>{invoice.vendorSnapshotName}</div>
|
||||
{invoice.vendorSnapshotTaxNo ? <div className="mt-1 text-sm text-muted-foreground">Tax No: {invoice.vendorSnapshotTaxNo}</div> : null}
|
||||
</div>
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>Dates</div>
|
||||
<div className="mt-2 text-sm text-muted-foreground">Vendor invoice date: {new Date(invoice.vendorInvoiceDate).toLocaleDateString()}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Printed: {new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>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">{formatAmount(invoice.totals.subtotal)}</div>
|
||||
<div className="text-muted-foreground">VAT</div><div className="text-right font-medium">{formatAmount(invoice.totals.vatTotal)}</div>
|
||||
<div className="text-muted-foreground">Grand total</div><div className={`text-right ${layout.totalsHighlight}`}>{formatAmount(invoice.totals.grandTotal)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={layout.tableWrap}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className={layout.tableHeaderRow}>
|
||||
<TableHead>GRN</TableHead>
|
||||
<TableHead>Posted</TableHead>
|
||||
<TableHead className="text-right">Subtotal</TableHead>
|
||||
<TableHead className="text-right">VAT</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invoice.lines.map((line) => (
|
||||
<TableRow key={line.purchaseInvoiceLineId}>
|
||||
<TableCell className="font-medium">{line.grnDocNo}</TableCell>
|
||||
<TableCell>{new Date(line.grnPostedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(line.subtotal)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(line.vatTotal)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatAmount(line.lineTotal)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { PurchaseInvoiceSummary } from "@/types/purchase-invoices"
|
||||
|
||||
function statusClass(status: PurchaseInvoiceSummary["status"]) {
|
||||
switch (status) {
|
||||
case "Draft":
|
||||
return "border-amber-200 bg-amber-50 text-amber-800"
|
||||
case "Posted":
|
||||
return "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||
case "Cancelled":
|
||||
return "border-rose-200 bg-rose-50 text-rose-800"
|
||||
}
|
||||
}
|
||||
|
||||
function PurchaseInvoiceBatchPrintContent() {
|
||||
const searchParams = useSearchParams()
|
||||
const status = searchParams.get("status") ?? "All"
|
||||
const query = searchParams.get("q") ?? ""
|
||||
const [rows, setRows] = useState<PurchaseInvoiceSummary[] | null>(null)
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
purchaseInvoicesApi.list({ page: 1, pageSize: 200 }),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
.then(([res, companyRes, templateRes]) => {
|
||||
setRows(res.items)
|
||||
setCompany(companyRes.data)
|
||||
setTemplate(templateRes.data)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const visibleRows = rows?.filter((row) => {
|
||||
const matchesStatus = status === "All" || row.status === status
|
||||
const matchesQuery = `${row.docNo} ${row.vendorInvoiceNo} ${row.vendorSnapshotName}`.toLowerCase().includes(query.toLowerCase())
|
||||
return matchesStatus && matchesQuery
|
||||
})
|
||||
|
||||
if (error && !rows) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
|
||||
if (!rows) {
|
||||
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Purchase invoice batch print data is loading or unavailable."}</div>
|
||||
}
|
||||
|
||||
const layout = getInvoiceLayoutClasses(template?.templateStyle)
|
||||
|
||||
return (
|
||||
<div className={`mx-auto flex w-full max-w-6xl flex-col rounded-3xl border bg-white shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none ${layout.sheet}`}>
|
||||
<div className="flex items-center justify-end print:hidden">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
Print
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={`md:grid ${layout.arrangement === "compact" ? "" : "md:grid-cols-[1.4fr_1fr]"} ${layout.header}`}>
|
||||
{layout.arrangement === "highlight" && <InvoicePrintHeader company={company} template={template} />}
|
||||
<div>
|
||||
<div className={layout.eyebrow}>Purchase Invoices</div>
|
||||
<h1 className={layout.title}>Purchase Invoice Batch Print</h1>
|
||||
<p className={layout.meta}>Printed register snapshot of current purchase invoices.</p>
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && <InvoicePrintHeader company={company} template={template} />}
|
||||
</div>
|
||||
|
||||
<div className={layout.tableWrap}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className={layout.tableHeaderRow}>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Vendor invoice no.</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">GRNs</TableHead>
|
||||
<TableHead className="text-right">Subtotal</TableHead>
|
||||
<TableHead className="text-right">VAT</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleRows?.map((row) => (
|
||||
<TableRow key={row.purchaseInvoiceId}>
|
||||
<TableCell className="font-medium">{row.docNo}</TableCell>
|
||||
<TableCell>{row.vendorInvoiceNo}</TableCell>
|
||||
<TableCell>{row.vendorSnapshotName}</TableCell>
|
||||
<TableCell>{new Date(row.vendorInvoiceDate).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="text-right">{row.grnCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(row.totals.subtotal)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(row.totals.vatTotal)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatAmount(row.totals.grandTotal)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={statusClass(row.status)}>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PurchaseInvoiceBatchPrintPage() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
|
||||
<PurchaseInvoiceBatchPrintContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -9,11 +9,18 @@ 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 { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||
import { InvoicePrintFooter } from "@/components/invoice-template/InvoicePrintFooter"
|
||||
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { SalesInvoice } from "@/types/sales"
|
||||
|
||||
export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
@@ -24,6 +31,8 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
const [items, setItems] = useState<ItemListItem[]>([])
|
||||
const [uoms, setUoms] = useState<Uom[]>([])
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,13 +47,17 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
uomsApi.list({ pageSize: 200 }),
|
||||
warehousesApi.list({ pageSize: 200 }),
|
||||
salesApi.getInvoice(invoiceId),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
.then(([cust, itemRes, uomRes, whRes, doc]) => {
|
||||
.then(([cust, itemRes, uomRes, whRes, doc, companyRes, templateRes]) => {
|
||||
setCustomers(cust.items)
|
||||
setItems(itemRes.items)
|
||||
setUoms(uomRes.items)
|
||||
setWarehouses(whRes.items)
|
||||
setInvoice(doc.data)
|
||||
setCompany(companyRes.data)
|
||||
setTemplate(templateRes.data)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [resolvedParams.id, invoiceId])
|
||||
@@ -61,9 +74,19 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
|
||||
const customer = customers.find((c) => c.customerId === invoice.customerId)
|
||||
const warehouse = warehouses.find((w) => w.warehouseId === invoice.warehouseId)
|
||||
const layout = getInvoiceLayoutClasses(template?.templateStyle)
|
||||
|
||||
const titleBlock = (
|
||||
<div>
|
||||
<div className={layout.eyebrow}>Sales Invoice</div>
|
||||
<h1 className={layout.title}>{invoice.invoiceNo}</h1>
|
||||
<div className={layout.meta}>Status: {invoice.status} · Type: {invoice.invoiceType}</div>
|
||||
</div>
|
||||
)
|
||||
const companyBlock = <InvoicePrintHeader company={company} template={template} />
|
||||
|
||||
return (
|
||||
<div className="invoice-sheet mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||
<div className={`invoice-sheet mx-auto flex w-full max-w-5xl flex-col rounded-3xl border bg-white shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none ${layout.sheet}`}>
|
||||
<div className="flex items-center justify-end print:hidden">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
@@ -71,50 +94,65 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="invoice-header 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>
|
||||
{layout.arrangement === "compact" ? (
|
||||
<div className={`invoice-header flex flex-wrap items-start justify-between ${layout.header}`}>
|
||||
{titleBlock}
|
||||
{companyBlock}
|
||||
</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">Invoice 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 className={`invoice-header grid md:grid-cols-[1.4fr_1fr] ${layout.header}`}>
|
||||
{layout.arrangement === "highlight" ? companyBlock : titleBlock}
|
||||
{layout.arrangement === "highlight" ? titleBlock : companyBlock}
|
||||
</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}
|
||||
{layout.arrangement === "highlight" && (
|
||||
<div className={layout.totalsBanner}>
|
||||
<span className={layout.totalsBannerLabel}>Net payable</span>
|
||||
<span className={layout.totalsBannerValue}>{invoice.totals.netPayable.toFixed(2)}</span>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{layout.arrangement === "compact" ? (
|
||||
<div className={`flex flex-wrap items-center text-sm ${layout.infoGrid}`}>
|
||||
<div><span className="text-muted-foreground">Customer: </span><span className="font-medium text-foreground">{invoice.customerSnapshotName}</span></div>
|
||||
<div><span className="text-muted-foreground">Warehouse: </span><span className="font-medium text-foreground">{warehouse?.name ?? `#${invoice.warehouseId}`}</span></div>
|
||||
<div><span className="text-muted-foreground">Free qty: </span><span className="font-medium text-foreground">{freeQtyTotal.toFixed(2)}</span></div>
|
||||
<div><span className="text-muted-foreground">Net payable: </span><span className={layout.totalsHighlight}>{invoice.totals.netPayable.toFixed(2)}</span></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 className={`grid ${layout.arrangement === "highlight" ? "md:grid-cols-2" : "md:grid-cols-3"} ${layout.infoGrid}`}>
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>Customer</div>
|
||||
<div className={layout.cardValue}>{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={layout.card}>
|
||||
<div className={layout.cardLabel}>Warehouse</div>
|
||||
<div className={layout.cardValue}>{warehouse?.name ?? `#${invoice.warehouseId}`}</div>
|
||||
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? invoice.warehouseId}</div>
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && (
|
||||
<div className={layout.card}>
|
||||
<div className={layout.cardLabel}>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 ${layout.totalsHighlight}`}>{invoice.totals.netPayable.toFixed(2)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border">
|
||||
<div className={layout.tableWrap}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableRow className={layout.tableHeaderRow}>
|
||||
<TableHead className="w-[34%]">Item</TableHead>
|
||||
<TableHead>UOM</TableHead>
|
||||
<TableHead className="text-right">Qty</TableHead>
|
||||
@@ -144,6 +182,8 @@ export default function SalesInvoicePrintPage({ params }: { params: Promise<{ id
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<InvoicePrintFooter company={company} template={template} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,11 +5,17 @@ import { useSearchParams } from "next/navigation"
|
||||
import { Printer } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { companyProfileApi } from "@/lib/api/company-profile"
|
||||
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { InvoicePrintHeader } from "@/components/invoice-template/InvoicePrintHeader"
|
||||
import { getInvoiceLayoutClasses } from "@/components/invoice-template/invoiceLayoutPresets"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
import { SalesInvoiceSummary } from "@/types/sales"
|
||||
|
||||
function statusClass(status: SalesInvoiceSummary["status"]) {
|
||||
@@ -28,13 +34,22 @@ function SalesInvoiceBatchPrintContent() {
|
||||
const status = searchParams.get("status") ?? "All"
|
||||
const query = searchParams.get("q") ?? ""
|
||||
const [rows, setRows] = useState<SalesInvoiceSummary[] | null>(null)
|
||||
const [company, setCompany] = useState<CompanyProfile | null>(null)
|
||||
const [template, setTemplate] = useState<InvoiceTemplateConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
salesApi
|
||||
.listInvoices({ page: 1, pageSize: 200 })
|
||||
.then((res) => setRows(res.items))
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
Promise.all([
|
||||
salesApi.listInvoices({ page: 1, pageSize: 200 }),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
])
|
||||
.then(([res, companyRes, templateRes]) => {
|
||||
setRows(res.items)
|
||||
setCompany(companyRes.data)
|
||||
setTemplate(templateRes.data)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
const visibleRows = rows?.filter((row) => {
|
||||
@@ -52,8 +67,10 @@ function SalesInvoiceBatchPrintContent() {
|
||||
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Invoice batch print data is loading or unavailable."}</div>
|
||||
}
|
||||
|
||||
const layout = getInvoiceLayoutClasses(template?.templateStyle)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
|
||||
<div className={`mx-auto flex w-full max-w-6xl flex-col rounded-3xl border bg-white shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none ${layout.sheet}`}>
|
||||
<div className="flex items-center justify-end print:hidden">
|
||||
<Button variant="outline" onClick={() => window.print()}>
|
||||
<Printer className="size-4" />
|
||||
@@ -61,16 +78,20 @@ function SalesInvoiceBatchPrintContent() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-b pb-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Invoices</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-foreground">Invoice Batch Print</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current invoices.</p>
|
||||
<div className={`md:grid ${layout.arrangement === "compact" ? "" : "md:grid-cols-[1.4fr_1fr]"} ${layout.header}`}>
|
||||
{layout.arrangement === "highlight" && <InvoicePrintHeader company={company} template={template} />}
|
||||
<div>
|
||||
<div className={layout.eyebrow}>Sales Invoices</div>
|
||||
<h1 className={layout.title}>Invoice Batch Print</h1>
|
||||
<p className={layout.meta}>Printed register snapshot of current invoices.</p>
|
||||
</div>
|
||||
{layout.arrangement !== "highlight" && <InvoicePrintHeader company={company} template={template} />}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border">
|
||||
<div className={layout.tableWrap}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableRow className={layout.tableHeaderRow}>
|
||||
<TableHead>Invoice</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
|
||||
@@ -214,7 +214,7 @@ const navItems: {
|
||||
children: [
|
||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
||||
{ title: "Configuration", code: "products.configuration", href: "/dashboard/products/settings", icon: SlidersHorizontal },
|
||||
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
{ title: "Help", code: "help", href: "/dashboard/help", icon: HelpCircle },
|
||||
|
||||
@@ -34,6 +34,7 @@ const SEGMENT_LABELS: Record<string, string> = {
|
||||
requisitions: "Requisitions",
|
||||
rfqs: "RFQs",
|
||||
"purchase-orders": "Purchase Orders",
|
||||
"purchase-invoices": "Purchase Invoices",
|
||||
"purchase-returns": "Purchase Returns",
|
||||
production: "Production",
|
||||
templates: "Templates",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig, InvoiceTemplateStyle } from "@/types/invoice-template"
|
||||
|
||||
interface InvoicePrintFooterProps {
|
||||
company: CompanyProfile | null
|
||||
template: InvoiceTemplateConfig | null
|
||||
}
|
||||
|
||||
// A real (not purely cosmetic) effect of the template style: how much breathing room and
|
||||
// accent the footer block gets, from denser to more formal.
|
||||
const styleClass: Record<InvoiceTemplateStyle, string> = {
|
||||
Compact: "border-t pt-3 gap-2",
|
||||
Classic: "border-t pt-5 gap-3",
|
||||
Modern: "border-t-2 border-t-primary pt-5 gap-3",
|
||||
}
|
||||
|
||||
/** Bank details, terms & conditions, and footer note for an invoice print view — sourced from
|
||||
* the shared Invoice Template Config and Company Profile. Used by both the Sales Invoice and
|
||||
* Purchase Invoice print pages. */
|
||||
export function InvoicePrintFooter({ company, template }: InvoicePrintFooterProps) {
|
||||
if (!template) return null
|
||||
|
||||
const hasBankDetails =
|
||||
template.showBankDetails && company && (company.bankName || company.accountNumber)
|
||||
|
||||
if (!hasBankDetails && !template.termsAndConditions.trim() && !template.footerNote.trim()) return null
|
||||
|
||||
return (
|
||||
<div className={`invoice-footer flex flex-col ${styleClass[template.templateStyle]}`}>
|
||||
{hasBankDetails && (
|
||||
<div className="grid gap-1 text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Bank details</div>
|
||||
{company!.bankName && <div className="text-muted-foreground">Bank: {company!.bankName}{company!.bankBranch ? ` — ${company!.bankBranch}` : ""}</div>}
|
||||
{company!.accountName && <div className="text-muted-foreground">Account name: {company!.accountName}</div>}
|
||||
{company!.accountNumber && <div className="text-muted-foreground">Account number: {company!.accountNumber}</div>}
|
||||
{company!.swiftCode && <div className="text-muted-foreground">SWIFT: {company!.swiftCode}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{template.termsAndConditions.trim() && (
|
||||
<div className="grid gap-1 text-sm">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Terms & conditions</div>
|
||||
<p className="whitespace-pre-wrap text-muted-foreground">{template.termsAndConditions}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{template.footerNote.trim() && (
|
||||
<p className="whitespace-pre-wrap text-xs text-muted-foreground">{template.footerNote}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { InvoiceTemplateConfig, LogoPlacement } from "@/types/invoice-template"
|
||||
|
||||
interface InvoicePrintHeaderProps {
|
||||
company: CompanyProfile | null
|
||||
template: InvoiceTemplateConfig | null
|
||||
}
|
||||
|
||||
/** Company identity block for an invoice print view — logo (per template.logoPlacement),
|
||||
* name/address/contact, and tax reg no (only when template.showTaxRegNo). Used by both the
|
||||
* Sales Invoice and Purchase Invoice print pages so they render from one shared source. */
|
||||
export function InvoicePrintHeader({ company, template }: InvoicePrintHeaderProps) {
|
||||
if (!company) {
|
||||
return (
|
||||
<div className="grid gap-2 text-sm md:justify-items-end">
|
||||
<div className="font-semibold text-foreground">Company details are not configured yet.</div>
|
||||
<div className="text-muted-foreground">Set them up under Settings → Company Profile.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const placement: LogoPlacement = template?.logoPlacement ?? "None"
|
||||
const showTaxRegNo = template?.showTaxRegNo ?? false
|
||||
const hasLogo = placement !== "None" && !!company.logoUrl
|
||||
|
||||
// Logo always sits on its own line above the address block (never beside it) — only
|
||||
// its horizontal position changes with placement, and the address text below matches
|
||||
// that same alignment so the whole block reads as one column, not two misaligned ones.
|
||||
const alignmentClasses: Record<LogoPlacement, string> = {
|
||||
Left: "justify-items-start text-left",
|
||||
Center: "justify-items-center text-center",
|
||||
Right: "justify-items-end text-right",
|
||||
None: "md:justify-items-end md:text-right",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid gap-2 text-sm ${alignmentClasses[placement]}`}>
|
||||
{hasLogo && (
|
||||
// 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-12 w-auto object-contain" />
|
||||
)}
|
||||
<div className="font-semibold text-foreground">{company.tradeName || company.legalName}</div>
|
||||
<div className="text-muted-foreground">{company.addressLine1}</div>
|
||||
{company.addressLine2 && <div className="text-muted-foreground">{company.addressLine2}</div>}
|
||||
<div className="text-muted-foreground">{[company.city, company.country].filter(Boolean).join(", ")}</div>
|
||||
{company.phone && <div className="text-muted-foreground">{company.phone}</div>}
|
||||
{company.email && <div className="text-muted-foreground">{company.email}</div>}
|
||||
{showTaxRegNo && company.taxRegistrationNo && (
|
||||
<div className="text-muted-foreground">Tax Reg No: {company.taxRegistrationNo}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { InvoiceTemplateStyle } from "@/types/invoice-template"
|
||||
|
||||
/** Which document structure a style renders as — not just color/spacing, the actual
|
||||
* placement of blocks on the page:
|
||||
* - "standard": title+doc no. on the left, company block on the right, then a 3-up
|
||||
* info row (party, second party detail, totals card), then the line table.
|
||||
* - "compact": title and company stacked into one dense strip, info collapsed into a
|
||||
* single inline summary line instead of three cards, to fit more on one page.
|
||||
* - "highlight": company block leads (left), title/doc no. trails (right) — reversed
|
||||
* from standard — then a full-width totals banner ahead of the line table instead of
|
||||
* a totals card buried in the 3-up row, with party/second-party as a 2-up row below it.
|
||||
*/
|
||||
export type InvoiceArrangement = "standard" | "compact" | "highlight"
|
||||
|
||||
/** Layout classes + arrangement that make each `InvoiceTemplateStyle` a genuinely
|
||||
* different print template (structure, density, accenting) rather than a cosmetic
|
||||
* tweak. Shared by the Sales Invoice and Purchase Invoice print pages so a style change
|
||||
* rearranges both document types identically. */
|
||||
export interface InvoiceLayoutClasses {
|
||||
arrangement: InvoiceArrangement
|
||||
sheet: string
|
||||
header: string
|
||||
eyebrow: string
|
||||
title: string
|
||||
meta: string
|
||||
infoGrid: string
|
||||
card: string
|
||||
cardLabel: string
|
||||
cardValue: string
|
||||
tableWrap: string
|
||||
tableHeaderRow: string
|
||||
totalsHighlight: string
|
||||
totalsBanner: string
|
||||
totalsBannerLabel: string
|
||||
totalsBannerValue: string
|
||||
}
|
||||
|
||||
const presets: Record<InvoiceTemplateStyle, InvoiceLayoutClasses> = {
|
||||
Classic: {
|
||||
arrangement: "standard",
|
||||
sheet: "gap-6 p-6",
|
||||
header: "gap-4 border-b pb-5",
|
||||
eyebrow: "text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground",
|
||||
title: "mt-2 text-3xl font-semibold text-foreground",
|
||||
meta: "mt-2 text-sm text-muted-foreground",
|
||||
infoGrid: "gap-4",
|
||||
card: "rounded-2xl border bg-muted/20 p-4",
|
||||
cardLabel: "text-xs font-semibold uppercase tracking-wide text-muted-foreground",
|
||||
cardValue: "mt-2 text-lg font-semibold text-foreground",
|
||||
tableWrap: "overflow-hidden rounded-2xl border",
|
||||
tableHeaderRow: "",
|
||||
totalsHighlight: "font-semibold text-foreground",
|
||||
totalsBanner: "hidden",
|
||||
totalsBannerLabel: "",
|
||||
totalsBannerValue: "",
|
||||
},
|
||||
Compact: {
|
||||
arrangement: "compact",
|
||||
sheet: "gap-3 p-4",
|
||||
header: "gap-1 border-b pb-2",
|
||||
eyebrow: "text-[10px] font-semibold uppercase tracking-widest text-muted-foreground",
|
||||
title: "text-xl font-semibold text-foreground",
|
||||
meta: "text-xs text-muted-foreground",
|
||||
infoGrid: "gap-x-6 gap-y-1",
|
||||
card: "rounded-md border bg-muted/10 p-2.5",
|
||||
cardLabel: "text-[10px] font-semibold uppercase tracking-wide text-muted-foreground",
|
||||
cardValue: "mt-1 text-sm font-semibold text-foreground",
|
||||
tableWrap: "overflow-hidden rounded-md border",
|
||||
tableHeaderRow: "",
|
||||
totalsHighlight: "font-semibold text-foreground",
|
||||
totalsBanner: "hidden",
|
||||
totalsBannerLabel: "",
|
||||
totalsBannerValue: "",
|
||||
},
|
||||
Modern: {
|
||||
arrangement: "highlight",
|
||||
sheet: "gap-6 p-6",
|
||||
header: "gap-4 border-b-2 border-b-primary pb-5",
|
||||
eyebrow: "text-xs font-semibold uppercase tracking-[0.3em] text-primary",
|
||||
title: "mt-2 text-3xl font-bold text-foreground",
|
||||
meta: "mt-2 text-sm text-muted-foreground",
|
||||
infoGrid: "gap-4",
|
||||
card: "rounded-2xl border border-primary/20 bg-primary/5 p-4",
|
||||
cardLabel: "text-xs font-semibold uppercase tracking-wide text-primary",
|
||||
cardValue: "mt-2 text-lg font-semibold text-foreground",
|
||||
tableWrap: "overflow-hidden rounded-2xl border border-primary/20",
|
||||
tableHeaderRow: "bg-primary/10 [&_th]:text-primary",
|
||||
totalsHighlight: "font-semibold text-primary",
|
||||
totalsBanner: "flex items-center justify-between rounded-2xl bg-primary px-5 py-4 text-primary-foreground",
|
||||
totalsBannerLabel: "text-xs font-semibold uppercase tracking-[0.2em] text-primary-foreground/80",
|
||||
totalsBannerValue: "text-2xl font-bold text-primary-foreground",
|
||||
},
|
||||
}
|
||||
|
||||
export function getInvoiceLayoutClasses(style: InvoiceTemplateStyle | undefined): InvoiceLayoutClasses {
|
||||
return presets[style ?? "Classic"]
|
||||
}
|
||||
+59
-31
@@ -3,11 +3,11 @@
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { grnsApi } from "@/lib/api/grns"
|
||||
import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
import { Grn, GrnPayment } from "@/types/grn"
|
||||
import { PurchaseInvoicePayment, PurchaseInvoiceStatus, PurchaseInvoiceSummary } from "@/types/purchase-invoices"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -16,16 +16,25 @@ import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface GrnPaymentDialogProps {
|
||||
grn: Grn | null
|
||||
interface PurchaseInvoicePaymentDialogProps {
|
||||
// PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog
|
||||
// touches (id/no/status/balance) is already on the summary, so a list row can open it
|
||||
// without a separate get() round trip. PurchaseInvoice extends PurchaseInvoiceSummary, so
|
||||
// the detail page's full invoice still satisfies this as-is.
|
||||
invoice: PurchaseInvoiceSummary | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onPaid: (grn: Grn, payment: GrnPayment) => 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. */
|
||||
onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void
|
||||
}
|
||||
|
||||
/** Pay the vendor against a confirmed GRN's balance — full or partial, from an existing
|
||||
* GL cash/bank account. Modeled on ReceivedChequeDialog's "pick an account, submit" shape. */
|
||||
export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPaymentDialogProps) {
|
||||
/** 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) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
@@ -33,15 +42,13 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Reset the form fields for a fresh open (or a different GRN) during render, React's own
|
||||
// sanctioned "adjust state while rendering" pattern (see the General Ledger report page's
|
||||
// periodKey comment) — not inside the effect below, which would be a synchronous
|
||||
// setState-in-effect (react-hooks/set-state-in-effect).
|
||||
const openKey = open && grn ? `${grn.grnId}` : null
|
||||
// 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.
|
||||
const openKey = open && invoice ? `${invoice.purchaseInvoiceId}` : null
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setAmount(grn!.balanceAmount.toFixed(2))
|
||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
||||
setGlBankAccountId("")
|
||||
setReference("")
|
||||
setErrors({})
|
||||
@@ -52,30 +59,46 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
|
||||
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
|
||||
}, [open, accounts])
|
||||
|
||||
if (!grn) return null
|
||||
if (!invoice) return null
|
||||
|
||||
async function submit() {
|
||||
if (!grn) return
|
||||
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 > grn.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(grn.balanceAmount)})`
|
||||
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const payment = await grnsApi.pay(grn.grnId, {
|
||||
if (invoice.status === "Draft") {
|
||||
try {
|
||||
await purchaseInvoicesApi.post(invoice.purchaseInvoiceId)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
toast.success("Payment recorded", `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}).`)
|
||||
onPaid(grn, payment)
|
||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
||||
toast.success(
|
||||
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
||||
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
||||
)
|
||||
onPaid(payment, "Posted")
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error("Could not record payment", errorMessage(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)
|
||||
}
|
||||
@@ -85,18 +108,19 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay GRN {grn.docNo}</DialogTitle>
|
||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(grn.balanceAmount)}</span>
|
||||
{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>
|
||||
</p>
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.glBankAccountId}>
|
||||
<FieldLabel htmlFor="grn-pay-account">Pay from</FieldLabel>
|
||||
<FieldLabel htmlFor="pinv-pay-account">Pay from</FieldLabel>
|
||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="grn-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
||||
<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>
|
||||
@@ -111,13 +135,13 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="grn-pay-amount">Amount</FieldLabel>
|
||||
<FieldLabel htmlFor="pinv-pay-amount">Amount</FieldLabel>
|
||||
<Input
|
||||
id="grn-pay-amount"
|
||||
id="pinv-pay-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={grn.balanceAmount}
|
||||
max={invoice.totals.balanceAmount}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-invalid={!!errors.amount}
|
||||
@@ -126,8 +150,8 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="grn-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="grn-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
<FieldLabel htmlFor="pinv-pay-reference">Reference (optional)</FieldLabel>
|
||||
<Input id="pinv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
@@ -136,7 +160,11 @@ export function GrnPaymentDialog({ grn, open, onOpenChange, onPaid }: GrnPayment
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : "Record payment"}
|
||||
{submitting
|
||||
? "Recording…"
|
||||
: invoice.status === "Draft"
|
||||
? "Confirm & record payment"
|
||||
: "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { PurchaseOrderStatus, RequisitionStatus, RfqStatus } from "@/types/procurement"
|
||||
import { PurchaseInvoiceStatus } from "@/types/purchase-invoices"
|
||||
|
||||
// Fixed width (not w-fit) so every status pill is the same size regardless of
|
||||
// label length, same convention as components/receiving/status-badges.tsx.
|
||||
@@ -43,3 +44,53 @@ export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function purchaseInvoiceClass(status: PurchaseInvoiceStatus) {
|
||||
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
|
||||
if (status === "Posted") return "bg-success/10 text-success border-transparent"
|
||||
return "bg-destructive/10 text-destructive border-transparent" // Cancelled
|
||||
}
|
||||
|
||||
// "Posted" reads as "Approved" everywhere in the UI, matching SalesInvoiceStatusBadge.
|
||||
const purchaseInvoiceStatusLabel: Record<PurchaseInvoiceStatus, string> = {
|
||||
Draft: "Draft",
|
||||
Posted: "Approved",
|
||||
Cancelled: "Cancelled",
|
||||
}
|
||||
|
||||
export function PurchaseInvoiceStatusBadge({ status }: { status: PurchaseInvoiceStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoiceClass(status)}`}>
|
||||
{purchaseInvoiceStatusLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export type PurchaseInvoicePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
|
||||
|
||||
export function purchaseInvoicePaymentStatus(paidAmount: number, balanceAmount: number): PurchaseInvoicePaymentStatus {
|
||||
if (balanceAmount <= 0) return "Paid"
|
||||
if (paidAmount > 0) return "PartiallyPaid"
|
||||
return "Unpaid"
|
||||
}
|
||||
|
||||
function purchaseInvoicePaymentClass(status: PurchaseInvoicePaymentStatus) {
|
||||
if (status === "Paid") return "bg-success/10 text-success border-transparent"
|
||||
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
|
||||
return "bg-muted text-muted-foreground border-transparent" // Unpaid
|
||||
}
|
||||
|
||||
const purchaseInvoicePaymentLabel: Record<PurchaseInvoicePaymentStatus, string> = {
|
||||
Unpaid: "Unpaid",
|
||||
PartiallyPaid: "Partially paid",
|
||||
Paid: "Paid",
|
||||
}
|
||||
|
||||
export function PurchaseInvoicePaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
|
||||
const status = purchaseInvoicePaymentStatus(paidAmount, balanceAmount)
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${purchaseInvoicePaymentClass(status)}`}>
|
||||
{purchaseInvoicePaymentLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,32 +32,3 @@ export function HoldStatusBadge({ status }: { status: HoldStatus }) {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export type GrnPaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
|
||||
|
||||
export function grnPaymentStatus(paidAmount: number, balanceAmount: number): GrnPaymentStatus {
|
||||
if (balanceAmount <= 0) return "Paid"
|
||||
if (paidAmount > 0) return "PartiallyPaid"
|
||||
return "Unpaid"
|
||||
}
|
||||
|
||||
function paymentClass(status: GrnPaymentStatus) {
|
||||
if (status === "Paid") return "bg-success/10 text-success border-transparent"
|
||||
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
|
||||
return "bg-muted text-muted-foreground border-transparent" // Unpaid
|
||||
}
|
||||
|
||||
const paymentLabel: Record<GrnPaymentStatus, string> = {
|
||||
Unpaid: "Unpaid",
|
||||
PartiallyPaid: "Partial",
|
||||
Paid: "Paid",
|
||||
}
|
||||
|
||||
export function GrnPaymentStatusBadge({ paidAmount, balanceAmount }: { paidAmount: number; balanceAmount: number }) {
|
||||
const status = grnPaymentStatus(paidAmount, balanceAmount)
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
|
||||
{paymentLabel[status]}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ interface SalesInvoicePaymentDialogProps {
|
||||
/** Record a customer payment against an 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 GrnPaymentDialog's "pick an account, submit" shape. */
|
||||
* invoice skips straight to payment. "Pick an account, submit" shape, mirrored by
|
||||
* PurchaseInvoicePaymentDialog on the AP side. */
|
||||
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
@@ -42,7 +43,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
||||
// "adjust state while rendering" pattern as GrnPaymentDialog, not inside an effect.
|
||||
// "adjust state while rendering" pattern, not inside an effect.
|
||||
const openKey = open && invoice ? `${invoice.salesInvoiceId}` : null
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"min-h-20 w-full min-w-0 rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,94 @@
|
||||
// ⚠️ TEMPORARY MOCK for manual browser testing — see /tmp/erp-mock-backup for the real
|
||||
// (backend-not-built-yet) client. Restore before committing.
|
||||
import { ApiResult } from "@/types/common"
|
||||
import { CompanyProfile, UpdateCompanyProfileRequest } from "@/types/company-profile"
|
||||
|
||||
const STORAGE_KEY = "mock:company-profile"
|
||||
|
||||
const defaultProfile: CompanyProfile = {
|
||||
legalName: "Hexa Trading (Private) Limited",
|
||||
tradeName: "Hexa ERP",
|
||||
taxRegistrationNo: "TRN-994521",
|
||||
vatRegistrationNo: "VAT-118823",
|
||||
addressLine1: "No. 42, Galle Road",
|
||||
addressLine2: "Colombo 03",
|
||||
city: "Colombo",
|
||||
country: "Sri Lanka",
|
||||
phone: "+94 11 234 5678",
|
||||
email: "accounts@hexatrading.example",
|
||||
bankName: "Commercial Bank of Ceylon",
|
||||
bankBranch: "Colombo Main",
|
||||
accountName: "Hexa Trading (Private) Limited",
|
||||
accountNumber: "8001234567",
|
||||
swiftCode: "CCEYLKLX",
|
||||
logoUrl: null,
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
// The print page opens in a new tab (target="_blank"), which is a fresh JS load — a plain
|
||||
// module-level variable would reset to defaultProfile and never see what Settings saved.
|
||||
// localStorage is the same origin across tabs, so it survives that reload.
|
||||
function readProfile(): CompanyProfile {
|
||||
if (typeof window === "undefined") return defaultProfile
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? { ...defaultProfile, ...JSON.parse(raw) } : defaultProfile
|
||||
} catch {
|
||||
return defaultProfile
|
||||
}
|
||||
}
|
||||
|
||||
function writeProfile(next: CompanyProfile) {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// best-effort mock persistence; ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
let profile: CompanyProfile = readProfile()
|
||||
|
||||
function etag() {
|
||||
return `"cp-${profile.updatedAt ?? "initial"}"`
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
export const companyProfileApi = {
|
||||
async get(): Promise<ApiResult<CompanyProfile>> {
|
||||
profile = readProfile()
|
||||
return { data: profile, etag: etag() }
|
||||
},
|
||||
|
||||
async update(request: UpdateCompanyProfileRequest, _ifMatch: string): Promise<ApiResult<CompanyProfile>> {
|
||||
profile = { ...profile, ...request, updatedAt: new Date().toISOString(), updatedBy: 1 }
|
||||
writeProfile(profile)
|
||||
return { data: profile, etag: etag() }
|
||||
},
|
||||
|
||||
async uploadLogo(file: File): Promise<ApiResult<CompanyProfile>> {
|
||||
const logoUrl = await readAsDataUrl(file)
|
||||
profile = { ...profile, logoUrl, updatedAt: new Date().toISOString(), updatedBy: 1 }
|
||||
writeProfile(profile)
|
||||
return { data: profile, etag: etag() }
|
||||
},
|
||||
|
||||
async deleteLogo(): Promise<ApiResult<CompanyProfile>> {
|
||||
profile = { ...profile, logoUrl: null, updatedAt: new Date().toISOString(), updatedBy: 1 }
|
||||
writeProfile(profile)
|
||||
return { data: profile, etag: etag() }
|
||||
},
|
||||
}
|
||||
|
||||
export async function companyProfile(): Promise<CompanyProfile> {
|
||||
return readProfile()
|
||||
}
|
||||
@@ -10,10 +10,8 @@ import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
ConfirmGrnResponse,
|
||||
CreateGrnPaymentRequest,
|
||||
CreateGrnRequest,
|
||||
Grn,
|
||||
GrnPayment,
|
||||
GrnStatus,
|
||||
GrnSummary,
|
||||
ReleaseAction,
|
||||
@@ -60,14 +58,4 @@ export const grnsApi = {
|
||||
body: { action },
|
||||
})
|
||||
},
|
||||
|
||||
/** Pay the vendor against the GRN's balance, in full or in installments. Posts a real GL journal entry. */
|
||||
pay(grnId: number, request: CreateGrnPaymentRequest): Promise<GrnPayment> {
|
||||
return apiRequest<GrnPayment>(`/grns/${grnId}/payments`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Payment history for a GRN, newest first. */
|
||||
listPayments(grnId: number): Promise<GrnPayment[]> {
|
||||
return apiRequest<GrnPayment[]>(`/grns/${grnId}/payments`)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// ⚠️ TEMPORARY MOCK for manual browser testing — see /tmp/erp-mock-backup for the real
|
||||
// (backend-not-built-yet) client. Restore before committing.
|
||||
import { ApiResult } from "@/types/common"
|
||||
import { InvoiceTemplateConfig, UpdateInvoiceTemplateConfigRequest } from "@/types/invoice-template"
|
||||
|
||||
const STORAGE_KEY = "mock:invoice-template-config"
|
||||
|
||||
const defaultConfig: InvoiceTemplateConfig = {
|
||||
templateStyle: "Classic",
|
||||
logoPlacement: "Left",
|
||||
showBankDetails: true,
|
||||
showTaxRegNo: true,
|
||||
footerNote: "Thank you for your business.",
|
||||
termsAndConditions: "Payment due within 30 days of invoice date.",
|
||||
updatedAt: null,
|
||||
updatedBy: null,
|
||||
}
|
||||
|
||||
// The print page opens in a new tab (target="_blank"), which is a fresh JS load — a plain
|
||||
// module-level variable would reset to defaultConfig and never see what Settings saved.
|
||||
// localStorage is the same origin across tabs, so it survives that reload.
|
||||
function readConfig(): InvoiceTemplateConfig {
|
||||
if (typeof window === "undefined") return defaultConfig
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? { ...defaultConfig, ...JSON.parse(raw) } : defaultConfig
|
||||
} catch {
|
||||
return defaultConfig
|
||||
}
|
||||
}
|
||||
|
||||
function writeConfig(next: InvoiceTemplateConfig) {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
|
||||
} catch {
|
||||
// best-effort mock persistence; ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
let config: InvoiceTemplateConfig = readConfig()
|
||||
|
||||
function etag() {
|
||||
return `"tmpl-${config.updatedAt ?? "initial"}"`
|
||||
}
|
||||
|
||||
export const invoiceTemplateConfigApi = {
|
||||
async get(): Promise<ApiResult<InvoiceTemplateConfig>> {
|
||||
config = readConfig()
|
||||
return { data: config, etag: etag() }
|
||||
},
|
||||
|
||||
async update(request: UpdateInvoiceTemplateConfigRequest, _ifMatch: string): Promise<ApiResult<InvoiceTemplateConfig>> {
|
||||
config = { ...request, updatedAt: new Date().toISOString(), updatedBy: 1 }
|
||||
writeConfig(config)
|
||||
return { data: config, etag: etag() }
|
||||
},
|
||||
}
|
||||
|
||||
export async function invoiceTemplateConfig(): Promise<InvoiceTemplateConfig> {
|
||||
return readConfig()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// One typed client method per Purchase Invoice endpoint. A purchase invoice consolidates
|
||||
// one or more Confirmed GRNs for the same vendor (each GRN becomes one line — see
|
||||
// types/purchase-invoices.ts); item-level detail stays on the GRN. Draft invoices are
|
||||
// editable via update(); post()/cancel() are the only status transitions.
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreatePurchaseInvoicePaymentRequest,
|
||||
CreatePurchaseInvoiceRequest,
|
||||
GrnEligibleForInvoice,
|
||||
PurchaseInvoice,
|
||||
PurchaseInvoiceListFilterParams,
|
||||
PurchaseInvoicePayment,
|
||||
PurchaseInvoiceSummary,
|
||||
UpdatePurchaseInvoiceRequest,
|
||||
} from "@/types/purchase-invoices"
|
||||
|
||||
export const purchaseInvoicesApi = {
|
||||
list(params: PurchaseInvoiceListFilterParams = {}): Promise<PagedResponse<PurchaseInvoiceSummary>> {
|
||||
return apiRequest<PagedResponse<PurchaseInvoiceSummary>>(`/purchase-invoices${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(purchaseInvoiceId: number): Promise<ApiResult<PurchaseInvoice>> {
|
||||
return apiRequestWithETag<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}`)
|
||||
},
|
||||
|
||||
/** 409 GRN_NOT_CONFIRMED / GRN_VENDOR_MISMATCH / GRN_ALREADY_INVOICED, or DUPLICATE_VENDOR_INVOICE_NO. */
|
||||
create(request: CreatePurchaseInvoiceRequest): Promise<ApiResult<PurchaseInvoice>> {
|
||||
return apiRequestWithETag<PurchaseInvoice>("/purchase-invoices", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Draft only (409 PURCHASE_INVOICE_NOT_EDITABLE otherwise). Echo the ETag from get()/create() as ifMatch. */
|
||||
update(purchaseInvoiceId: number, request: UpdatePurchaseInvoiceRequest, ifMatch: string): Promise<ApiResult<PurchaseInvoice>> {
|
||||
return apiRequestWithETag<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}`, {
|
||||
method: "PUT",
|
||||
body: request,
|
||||
ifMatch,
|
||||
})
|
||||
},
|
||||
|
||||
post(purchaseInvoiceId: number): Promise<PurchaseInvoice> {
|
||||
return apiRequest<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}/post`, { method: "POST" })
|
||||
},
|
||||
|
||||
/** Draft only. */
|
||||
cancel(purchaseInvoiceId: number): Promise<PurchaseInvoice> {
|
||||
return apiRequest<PurchaseInvoice>(`/purchase-invoices/${purchaseInvoiceId}/cancel`, { method: "POST" })
|
||||
},
|
||||
|
||||
/** Posted only (409 PURCHASE_INVOICE_NOT_PAYABLE); amount capped at the remaining balance. */
|
||||
pay(purchaseInvoiceId: number, request: CreatePurchaseInvoicePaymentRequest): Promise<PurchaseInvoicePayment> {
|
||||
return apiRequest<PurchaseInvoicePayment>(`/purchase-invoices/${purchaseInvoiceId}/payments`, {
|
||||
method: "POST",
|
||||
body: request,
|
||||
})
|
||||
},
|
||||
|
||||
/** Payment history, newest first. */
|
||||
listPayments(purchaseInvoiceId: number): Promise<PurchaseInvoicePayment[]> {
|
||||
return apiRequest<PurchaseInvoicePayment[]>(`/purchase-invoices/${purchaseInvoiceId}/payments`)
|
||||
},
|
||||
|
||||
/** Confirmed, not-yet-invoiced GRNs for a vendor — candidates for a new/edited invoice. */
|
||||
listEligibleGrns(vendorId: number): Promise<GrnEligibleForInvoice[]> {
|
||||
return apiRequest<GrnEligibleForInvoice[]>(`/purchase-invoices/eligible-grns${buildQuery({ vendorId })}`)
|
||||
},
|
||||
}
|
||||
@@ -35,6 +35,7 @@ const DETAIL_PREFERRED_CODES = new Set([
|
||||
"TRANSFER_EXCEEDS_AVAILABLE",
|
||||
"LEFTOVER_EXCEEDS_CONSUMED",
|
||||
"REQUIRED_FIELD_MISSING",
|
||||
"DUPLICATE_VENDOR_INVOICE_NO",
|
||||
])
|
||||
|
||||
const CODE_MESSAGES: Record<string, string> = {
|
||||
@@ -44,6 +45,11 @@ const CODE_MESSAGES: Record<string, string> = {
|
||||
ONHOLD_NOT_ISSUABLE: "This stock is on hold/quarantined and cannot be issued.",
|
||||
REASON_CODE_REQUIRED: "A reason code is required for this action.",
|
||||
PO_NOT_EDITABLE: "This purchase order can no longer be edited.",
|
||||
GRN_ALREADY_INVOICED: "This GRN has already been added to another purchase invoice.",
|
||||
GRN_VENDOR_MISMATCH: "All GRNs on a purchase invoice must be from the same vendor.",
|
||||
GRN_NOT_CONFIRMED: "Only confirmed GRNs can be added to a purchase invoice.",
|
||||
PURCHASE_INVOICE_NOT_EDITABLE: "This purchase invoice can no longer be edited.",
|
||||
DUPLICATE_VENDOR_INVOICE_NO: "This vendor invoice number has already been recorded for this vendor.",
|
||||
SKU_DUPLICATE: "That SKU already exists.",
|
||||
MASTER_IN_USE: "This record is referenced by other data and cannot be deleted.",
|
||||
CONCURRENCY_CONFLICT: "This record was changed by someone else. Reload and try again.",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Client-side UX validation only — same thin convention as lib/validations/sales.ts.
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function validateCompanyProfile(input: {
|
||||
legalName: string
|
||||
addressLine1: string
|
||||
city: string
|
||||
country: string
|
||||
phone: string
|
||||
email: string
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.legalName.trim()) errors.legalName = "Enter the company's legal name"
|
||||
if (!input.addressLine1.trim()) errors.addressLine1 = "Enter an address"
|
||||
if (!input.city.trim()) errors.city = "Enter a city"
|
||||
if (!input.country.trim()) errors.country = "Enter a country"
|
||||
if (!input.phone.trim()) errors.phone = "Enter a phone number"
|
||||
if (!input.email.trim()) errors.email = "Enter an email address"
|
||||
else if (!EMAIL_RE.test(input.email.trim())) errors.email = "Enter a valid email address"
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Client-side UX validation only — required fields, format/range checks the browser can
|
||||
// already see. Server-authoritative rules (GRN eligibility, vendor match, duplicate vendor
|
||||
// invoice numbers) are never re-implemented here. Same pattern as lib/validations/sales.ts.
|
||||
|
||||
export function validatePurchaseInvoiceHeader(input: {
|
||||
vendorId: number | null
|
||||
vendorInvoiceNo: string
|
||||
vendorInvoiceDate: string
|
||||
grnIds: number[]
|
||||
}): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!input.vendorId) errors.vendorId = "Select a vendor"
|
||||
if (!input.vendorInvoiceNo.trim()) errors.vendorInvoiceNo = "Enter the vendor's invoice number"
|
||||
if (!input.vendorInvoiceDate) {
|
||||
errors.vendorInvoiceDate = "Enter the vendor's invoice date"
|
||||
} else if (input.vendorInvoiceDate > new Date().toISOString().slice(0, 10)) {
|
||||
errors.vendorInvoiceDate = "Invoice date cannot be in the future"
|
||||
}
|
||||
if (input.grnIds.length === 0) errors.grnIds = "Select at least one GRN to consolidate"
|
||||
return errors
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Company Profile — singleton settings row (legal identity, contact, bank, logo) consumed by
|
||||
// the invoice print pages via components/invoice-template/InvoicePrintHeader.tsx. `footerNote`
|
||||
// deliberately lives on InvoiceTemplateConfig instead — this owns identity/contact/bank facts only.
|
||||
|
||||
export interface CompanyProfile {
|
||||
legalName: string
|
||||
tradeName: string
|
||||
taxRegistrationNo: string | null
|
||||
vatRegistrationNo: string | null
|
||||
addressLine1: string
|
||||
addressLine2: string | null
|
||||
city: string
|
||||
country: string
|
||||
phone: string
|
||||
email: string
|
||||
bankName: string | null
|
||||
bankBranch: string | null
|
||||
accountName: string | null
|
||||
accountNumber: string | null
|
||||
swiftCode: string | null
|
||||
logoUrl: string | null
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
}
|
||||
|
||||
/** Text fields only — the logo is managed separately via companyProfileApi.uploadLogo/deleteLogo. */
|
||||
export type UpdateCompanyProfileRequest = Omit<CompanyProfile, "logoUrl" | "updatedAt" | "updatedBy">
|
||||
@@ -102,8 +102,11 @@ export interface Grn {
|
||||
postedAt: string | null
|
||||
/** Journal number of the real GL journal entry posted for this GRN's receipt (set on confirm). */
|
||||
glJournalNo: string | null
|
||||
paidAmount: number
|
||||
balanceAmount: number
|
||||
/** Set once this GRN is consolidated into a Purchase Invoice — see types/purchase-invoices.ts.
|
||||
* Denormalized so GRN pages can show the link without an extra fetch. Vendor payment now
|
||||
* happens entirely on the Purchase Invoice, not here. */
|
||||
purchaseInvoiceId: number | null
|
||||
purchaseInvoiceNo: string | null
|
||||
lines: GrnLine[]
|
||||
}
|
||||
|
||||
@@ -118,8 +121,8 @@ export interface GrnSummary {
|
||||
createdAt: string
|
||||
postedAt: string | null
|
||||
lineCount: number
|
||||
paidAmount: number
|
||||
balanceAmount: number
|
||||
purchaseInvoiceId: number | null
|
||||
purchaseInvoiceNo: string | null
|
||||
}
|
||||
|
||||
/** docs/11 §4.2 confirm response. */
|
||||
@@ -139,7 +142,6 @@ export interface ConfirmGrnResponse {
|
||||
status: GrnStatus
|
||||
postedAt: string
|
||||
glJournalNo: string
|
||||
balanceAmount: number
|
||||
createdLayers: CreatedLayer[]
|
||||
ledgerRefs: number[]
|
||||
poStatus: PurchaseOrderStatus | null
|
||||
@@ -155,23 +157,3 @@ export interface ReleaseGrnLineResponse {
|
||||
grnLineId: number
|
||||
holdStatus: HoldStatus
|
||||
}
|
||||
|
||||
/** A vendor payment against a confirmed GRN's balance (installments allowed). */
|
||||
export interface GrnPayment {
|
||||
grnPaymentId: number
|
||||
grnId: number
|
||||
amount: number
|
||||
paymentDate: string
|
||||
glBankAccountId: number
|
||||
bankAccountName: string
|
||||
reference: string | null
|
||||
/** Journal number of the real GL journal entry this payment posted. */
|
||||
glJournalNo: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreateGrnPaymentRequest {
|
||||
amount: number
|
||||
glBankAccountId: number
|
||||
reference?: string | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Invoice Template Config — singleton settings row shared by both Sales Invoice and
|
||||
// Purchase Invoice print pages (see components/invoice-template/). Flat fields + RowVersion,
|
||||
// same shape as ProductConfig (types/master-data.ts), not a catalog/child-collection —
|
||||
// the field set here is small and fixed.
|
||||
|
||||
export type InvoiceTemplateStyle = "Classic" | "Compact" | "Modern"
|
||||
export type LogoPlacement = "Left" | "Center" | "Right" | "None"
|
||||
|
||||
export interface InvoiceTemplateConfig {
|
||||
templateStyle: InvoiceTemplateStyle
|
||||
logoPlacement: LogoPlacement
|
||||
showBankDetails: boolean
|
||||
showTaxRegNo: boolean
|
||||
footerNote: string
|
||||
termsAndConditions: string
|
||||
updatedAt: string | null
|
||||
updatedBy: number | null
|
||||
}
|
||||
|
||||
/** All fields are required; a partial body is a 400, never a silent disable (same rule as ProductConfig). */
|
||||
export type UpdateInvoiceTemplateConfigRequest = Omit<InvoiceTemplateConfig, "updatedAt" | "updatedBy">
|
||||
@@ -0,0 +1,94 @@
|
||||
// Purchase Invoice (AP) types — mirrors the assumed backend contract (see the approved
|
||||
// plan at .claude/plans/silly-roaming-codd.md Part A.1). A PurchaseInvoiceGrnLine is one
|
||||
// consolidated GRN, not a re-itemization of GRN item lines — the GRN already owns
|
||||
// item-level detail.
|
||||
|
||||
export type PurchaseInvoiceStatus = "Draft" | "Posted" | "Cancelled"
|
||||
|
||||
export interface PurchaseInvoiceGrnLine {
|
||||
purchaseInvoiceLineId: number
|
||||
grnId: number
|
||||
grnDocNo: string
|
||||
grnPostedAt: string
|
||||
subtotal: number
|
||||
vatTotal: number
|
||||
lineTotal: number
|
||||
}
|
||||
|
||||
export interface PurchaseInvoiceTotals {
|
||||
subtotal: number
|
||||
vatTotal: number
|
||||
grandTotal: number
|
||||
paidAmount: number
|
||||
balanceAmount: number
|
||||
}
|
||||
|
||||
export interface PurchaseInvoiceSummary {
|
||||
purchaseInvoiceId: number
|
||||
docNo: string
|
||||
/** The vendor's own invoice/bill number — captured nowhere else in the app. */
|
||||
vendorInvoiceNo: string
|
||||
vendorInvoiceDate: string
|
||||
vendorId: number
|
||||
vendorSnapshotName: string
|
||||
status: PurchaseInvoiceStatus
|
||||
grnCount: number
|
||||
totals: PurchaseInvoiceTotals
|
||||
createdAt: string
|
||||
glJournalNo: string | null
|
||||
}
|
||||
|
||||
export interface PurchaseInvoice extends PurchaseInvoiceSummary {
|
||||
vendorSnapshotTaxNo: string | null
|
||||
createdBy: number
|
||||
updatedAt: string | null
|
||||
glPostedAt: string | null
|
||||
lines: PurchaseInvoiceGrnLine[]
|
||||
}
|
||||
|
||||
export interface PurchaseInvoicePayment {
|
||||
purchaseInvoicePaymentId: number
|
||||
purchaseInvoiceId: number
|
||||
amount: number
|
||||
paymentDate: string
|
||||
glBankAccountId: number
|
||||
bankAccountName: string
|
||||
reference: string | null
|
||||
glJournalNo: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface CreatePurchaseInvoicePaymentRequest {
|
||||
amount: number
|
||||
glBankAccountId: number
|
||||
reference?: string | null
|
||||
}
|
||||
|
||||
export interface CreatePurchaseInvoiceRequest {
|
||||
vendorId: number
|
||||
vendorInvoiceNo: string
|
||||
vendorInvoiceDate: string
|
||||
/** Must be non-empty, Confirmed, same vendor, and not already linked to another invoice. */
|
||||
grnIds: number[]
|
||||
}
|
||||
|
||||
export type UpdatePurchaseInvoiceRequest = CreatePurchaseInvoiceRequest
|
||||
|
||||
/** A Confirmed, not-yet-invoiced GRN for a vendor — offered when building a new Purchase Invoice. */
|
||||
export interface GrnEligibleForInvoice {
|
||||
grnId: number
|
||||
docNo: string
|
||||
postedAt: string
|
||||
warehouseId: number
|
||||
lineCount: number
|
||||
subtotal: number
|
||||
vatTotal: number
|
||||
grandTotal: number
|
||||
}
|
||||
|
||||
export interface PurchaseInvoiceListFilterParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
status?: PurchaseInvoiceStatus
|
||||
vendorId?: number
|
||||
}
|
||||
Reference in New Issue
Block a user