ui and bug fixers 29/09

This commit is contained in:
2026-09-29 14:49:11 +05:30
parent d24072e20a
commit b2646c6cb3
85 changed files with 30050 additions and 750 deletions
@@ -0,0 +1,342 @@
"use client"
import { useEffect, useState } from "react"
import { grnsApi } from "@/lib/api/grns"
import { itemsApi } from "@/lib/api/items"
import { vendorsApi } from "@/lib/api/vendors"
import { warehousesApi } from "@/lib/api/warehouses"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { companyProfileApi } from "@/lib/api/company-profile"
import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config"
import { errorMessage } from "@/lib/error-map"
import {
A4_SHEET_CLASS,
ACCENT,
DocumentPrintDialog,
DocumentPrintPreview,
HeaderRule,
PaperSize,
ReceiptRow,
ReceiptRule,
SectionTitle,
formatDate,
money,
printColorExact,
qty,
receiptSheetClass,
} from "@/components/print/DocumentPrint"
import { useAuth } from "@/components/auth/AuthProvider"
import { CompanyProfile } from "@/types/company-profile"
import { Grn, GrnLine, HoldStatus } from "@/types/grn"
import { InvoiceTemplateConfig } from "@/types/invoice-template"
import { Item, Vendor, Warehouse } from "@/types/master-data"
/** Everything both layouts render, resolved once. */
interface GrnDoc {
grn: Grn
vendor: Vendor | null
warehouse: Warehouse | null
poNo: string | null
items: Map<number, Item>
company: CompanyProfile | null
showLogo: boolean
/** Print the warehouse's branch — only meaningful while warehouses are divided by branch. */
showBranch?: boolean
totals: { gross: number; discount: number; net: number; vat: number; total: number }
}
const HOLD_LABEL: Record<HoldStatus, string> = { Available: "", OnHold: "On hold", Rejected: "Rejected" }
function itemLabel(doc: GrnDoc, line: GrnLine) {
const item = doc.items.get(line.itemId)
return item ? item.name : `Item #${line.itemId}`
}
function A4Sheet(doc: GrnDoc) {
const { grn, vendor, warehouse, poNo, company, showLogo, totals } = doc
return (
<div className={A4_SHEET_CLASS} style={printColorExact}>
{/* Title + meta */}
<div className="flex items-start justify-between gap-6">
<div>
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">GOODS RECEIPT NOTE</h1>
<dl className="mt-5 grid grid-cols-[auto_1fr] gap-x-10 gap-y-1.5">
<dt className="text-xs font-bold text-neutral-900">GRN No.</dt>
<dd>{grn.docNo}</dd>
<dt className="text-xs font-bold text-neutral-900">Received Date</dt>
<dd>{formatDate(grn.postedAt ?? grn.createdAt)}</dd>
<dt className="text-xs font-bold text-neutral-900">Purchase Order</dt>
<dd>{grn.poId ? (poNo ?? `PO #${grn.poId}`) : "Direct receipt"}</dd>
{grn.purchaseInvoiceNo && (
<>
<dt className="text-xs font-bold text-neutral-900">Purchase Invoice</dt>
<dd>{grn.purchaseInvoiceNo}</dd>
</>
)}
{grn.status !== "Confirmed" && (
<>
<dt className="text-xs font-bold text-neutral-900">Status</dt>
<dd className="font-semibold" style={{ color: ACCENT }}>{grn.status.toUpperCase()}</dd>
</>
)}
</dl>
</div>
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company!.logoUrl!} alt={company!.legalName} className="h-16 w-auto max-w-45 object-contain" />
)}
</div>
<HeaderRule />
{/* Supplier / Received at */}
<div className="grid grid-cols-2 gap-10">
<div>
<SectionTitle>Supplier</SectionTitle>
<div className="leading-snug">
<p className="font-bold text-neutral-900">{vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
{vendor && <p>Code: {vendor.code}</p>}
{vendor?.taxReg && <p>Tax Reg No: {vendor.taxReg}</p>}
{vendor?.terms && <p>Terms: {vendor.terms}</p>}
</div>
</div>
<div>
<SectionTitle>Received At</SectionTitle>
<div className="leading-snug">
{company && <p className="font-bold text-neutral-900">{company.tradeName || company.legalName}</p>}
<p>{warehouse ? `${warehouse.name} (${warehouse.code})` : `Warehouse #${grn.warehouseId}`}</p>
{warehouse?.address && <p>{warehouse.address}</p>}
{doc.showBranch && warehouse?.branchName && <p>Branch: {warehouse.branchName}</p>}
{warehouse?.phone && <p>{warehouse.phone}</p>}
</div>
</div>
</div>
{/* Line items */}
<div className="mt-8">
<SectionTitle>Items Received</SectionTitle>
<table className="w-full border-collapse">
<thead>
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
<th className="px-2 py-2.5 text-left">Item</th>
<th className="px-2 py-2.5 text-center">Qty</th>
<th className="px-2 py-2.5 text-right">Unit Cost</th>
<th className="px-2 py-2.5 text-right">Discount</th>
<th className="px-2 py-2.5 text-right">VAT</th>
<th className="px-2 py-2.5 text-right">Amount</th>
</tr>
</thead>
<tbody>
{grn.lines.map((line) => {
const item = doc.items.get(line.itemId)
return (
<tr key={line.grnLineId} className="border-b border-neutral-300 align-top">
<td className="px-2 py-2.5">
<p>{itemLabel(doc, line)}</p>
<p className="text-xs text-neutral-500">
{[
item?.sku && `SKU ${item.sku}`,
HOLD_LABEL[line.holdStatus],
].filter(Boolean).join(" · ")}
</p>
{line.warrantyNumbers.length > 0 && (
<p className="text-xs text-neutral-500">Warranty: {line.warrantyNumbers.map((w) => w.warrantyNo).join(", ")}</p>
)}
</td>
<td className="px-2 py-2.5 text-center tabular-nums">{qty(line.qty)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{money(line.unitCost)}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{line.discountPct > 0 ? `${qty(line.discountPct)}%` : "—"}</td>
<td className="px-2 py-2.5 text-right tabular-nums">{line.vatAmount > 0 ? money(line.vatAmount) : "—"}</td>
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(line.lineTotal)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Totals */}
<div className="mt-6 flex justify-end print:break-inside-avoid">
<div className="w-72.5 border border-neutral-900">
<div className="space-y-1.5 px-3.5 py-3 text-xs">
<div className="flex justify-between font-bold text-neutral-900"><span>Gross</span><span className="tabular-nums">{money(totals.gross)}</span></div>
{totals.discount > 0 && <div className="flex justify-between"><span>Discount</span><span className="tabular-nums">-{money(totals.discount)}</span></div>}
{totals.vat > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(totals.vat)}</span></div>}
</div>
<div className="flex justify-between border-t border-neutral-300 bg-neutral-100 px-3.5 py-3 text-sm font-bold text-neutral-900">
<span>Total</span>
<span className="tabular-nums">{money(totals.total)}</span>
</div>
</div>
</div>
{/* Sign-off */}
<div className="mt-16 grid grid-cols-3 gap-8 text-xs print:break-inside-avoid">
{["Received by", "Checked by", "Approved by"].map((label) => (
<div key={label}>
<div className="border-t border-neutral-900 pt-1.5 font-bold text-neutral-900">{label}</div>
</div>
))}
</div>
</div>
)
}
function ReceiptSheet({ narrow, ...doc }: GrnDoc & { narrow?: boolean }) {
const { grn, vendor, warehouse, poNo, company, showLogo, totals } = doc
return (
<div className={receiptSheetClass(narrow)} style={printColorExact}>
<div className="text-center">
{showLogo && (
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
)}
{company && <p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>}
<p className="mt-2 text-sm font-extrabold tracking-[0.15em]">GOODS RECEIPT NOTE</p>
{grn.status !== "Confirmed" && <p className="font-bold">*** {grn.status.toUpperCase()} ***</p>}
</div>
<ReceiptRule />
<ReceiptRow label="GRN No." value={grn.docNo} />
<ReceiptRow label="Date" value={formatDate(grn.postedAt ?? grn.createdAt)} />
<ReceiptRow label="PO" value={grn.poId ? (poNo ?? `#${grn.poId}`) : "Direct"} />
<ReceiptRow label="Supplier" value={vendor?.name ?? `#${grn.vendorId}`} />
<ReceiptRow label="Warehouse" value={warehouse?.code ?? `#${grn.warehouseId}`} />
<ReceiptRule />
<div className="flex justify-between font-bold">
<span>Item</span>
<span>Amount</span>
</div>
<ReceiptRule />
<div className="space-y-1.5">
{grn.lines.map((line) => (
<div key={line.grnLineId}>
<p className="font-medium">
{itemLabel(doc, line)}
{HOLD_LABEL[line.holdStatus] && ` (${HOLD_LABEL[line.holdStatus].toLowerCase()})`}
</p>
<div className="flex justify-between gap-2 tabular-nums">
<span>{qty(line.qty)} x {money(line.unitCost)}</span>
<span>{money(line.lineTotal)}</span>
</div>
{line.discountPct > 0 && <p className="text-right tabular-nums">Disc {qty(line.discountPct)}%</p>}
</div>
))}
</div>
<ReceiptRule />
<ReceiptRow label="Gross" value={money(totals.gross)} />
{totals.discount > 0 && <ReceiptRow label="Discount" value={`-${money(totals.discount)}`} />}
{totals.vat > 0 && <ReceiptRow label="VAT" value={money(totals.vat)} />}
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
<ReceiptRow label="TOTAL" value={money(totals.total)} bold />
</div>
<div className="mt-6 space-y-5">
<div className="border-t border-black pt-1">Received by</div>
<div className="border-t border-black pt-1">Checked by</div>
</div>
</div>
)
}
function Sheet({ size, doc }: { size: PaperSize; doc: GrnDoc }) {
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} narrow={size === "58mm"} />
}
/** Loads the GRN plus its decoration (vendor, warehouse, PO no, item names, company). Only the
* GRN is required — any other failure just leaves that detail out. */
function useGrnDoc(grnId: number) {
const [doc, setDoc] = useState<GrnDoc | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const error = Number.isFinite(grnId) ? loadError : `Invalid GRN id '${grnId}'.`
useEffect(() => {
if (!Number.isFinite(grnId)) return
let cancelled = false
grnsApi
.get(grnId)
.then(async (grn) => {
const itemIds = [...new Set(grn.lines.map((l) => l.itemId))]
const [vendorRes, warehouse, po, itemRes, companyRes, templateRes] = await Promise.all([
vendorsApi.get(grn.vendorId).catch(() => null),
warehousesApi.get(grn.warehouseId).catch(() => null),
grn.poId ? purchaseOrdersApi.get(grn.poId).catch(() => null) : Promise.resolve(null),
Promise.all(itemIds.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null))),
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
])
if (cancelled) return
const company = companyRes.data
const template: InvoiceTemplateConfig | null = templateRes.data
const gross = grn.lines.reduce((s, l) => s + l.qty * l.unitCost, 0)
const net = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
setDoc({
grn,
vendor: vendorRes?.data ?? null,
warehouse,
poNo: po?.docNo ?? null,
items: new Map(itemRes.filter((i): i is Item => i !== null).map((i) => [i.itemId, i])),
company,
showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl,
totals: { gross, discount: gross - net, net, vat, total: grn.lines.reduce((s, l) => s + l.lineTotal, 0) },
})
})
.catch((err) => {
if (!cancelled) setLoadError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [grnId])
// "Divide warehouses by branch" is always off when "Require branches" is, so this one
// flag covers both switches.
const { warehouseBranchingEnabled } = useAuth()
return { doc: doc && { ...doc, showBranch: warehouseBranchingEnabled }, error }
}
function shareText({ grn, vendor, company, totals }: GrnDoc) {
const at = company ? ` at ${company.tradeName || company.legalName}` : ""
const from = vendor ? ` from ${vendor.name}` : ""
return `Goods Receipt Note ${grn.docNo}${at} — goods received${from}, total ${money(totals.total)}.`
}
/** GRN preview with paper size, Download / Send / Print (see DocumentPrintPreview). */
export function GrnPrintPreview({ grnId, className }: { grnId: number; className?: string }) {
const { doc, error } = useGrnDoc(grnId)
return (
<DocumentPrintPreview
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
error={error}
fileName={doc?.grn.docNo ?? "grn"}
share={doc ? { title: `GRN ${doc.grn.docNo}`, text: shareText(doc) } : null}
className={className}
/>
)
}
/** Popup form of the GRN preview. */
export function GrnPrintDialog({
grnId,
docNo,
open,
onOpenChange,
}: {
grnId: number | null
docNo?: string
open: boolean
onOpenChange: (open: boolean) => void
}) {
return (
<DocumentPrintDialog title={`GRN${docNo ? ` ${docNo}` : ""}`} open={open} onOpenChange={onOpenChange}>
{grnId !== null && <GrnPrintPreview grnId={grnId} className="flex-1" />}
</DocumentPrintDialog>
)
}