feat: add vendor optional fields and configuration hooks
- Implemented VendorOptionalFields component to handle optional vendor details (mobile, email, address) with validation. - Created useCustomerConfig hook to load optional customer field settings from Sales configuration. - Developed useProcurementConfig hook to manage Procurement configuration flags. - Introduced accessConfigApi for managing access configuration settings. - Added openingBalancesApi for handling opening balances operations. - Created procurementConfigApi for managing procurement configuration settings. - Implemented nav-routes for permission-based routing. - Developed PDF generation for purchase orders with dynamic vendor and item details. - Added purchase-order tax helpers for tax calculations and formatting. - Defined types for opening balances and related operations.
This commit is contained in:
@@ -67,7 +67,8 @@ import { useAuth } from "@/components/auth/AuthProvider"
|
||||
// `code` must match the seeded NavItem/SubNavItem codes in ERPCore
|
||||
// (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs)
|
||||
// so role-based filtering (via GET /auth/me's navCodes) can match entries here.
|
||||
const navItems: {
|
||||
// Exported: lib/nav-routes.ts derives the page -> permission map (the route guard) from this same list.
|
||||
export const navItems: {
|
||||
title: string
|
||||
code: string
|
||||
href: string
|
||||
@@ -114,6 +115,7 @@ const navItems: {
|
||||
icon: ClipboardList,
|
||||
children: [
|
||||
{ title: "Purchase Orders", code: "procurement.purchase-orders", href: "/dashboard/procurement/purchase-orders", icon: ShoppingCart },
|
||||
{ title: "PO Approval Queue", code: "procurement.po-approvals", href: "/dashboard/procurement/purchase-orders/approvals", icon: ClipboardCheck },
|
||||
{ title: "Purchase Returns", code: "procurement.purchase-returns", href: "/dashboard/procurement/purchase-returns", icon: PackageX },
|
||||
{ title: "Vendors", code: "procurement.vendors", href: "/dashboard/vendors", icon: Truck },
|
||||
],
|
||||
@@ -215,6 +217,7 @@ const navItems: {
|
||||
{ title: "Expenses", code: "accounts.expenses", href: "/dashboard/accounts/expenses", icon: ReceiptText },
|
||||
{ title: "Expense Setup", code: "accounts.expense-setup", href: "/dashboard/accounts/expense-setup", icon: Tags },
|
||||
{ title: "Bank Deposits", code: "accounts.bank-deposits", href: "/dashboard/accounts/bank-deposits", icon: PiggyBank },
|
||||
{ title: "Opening Balances", code: "accounts.opening-balances", href: "/dashboard/accounts/opening-balances", icon: Scale },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -227,6 +230,7 @@ const navItems: {
|
||||
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
|
||||
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
|
||||
{ title: "Approvals", code: "settings.approvals", href: "/dashboard/settings/approvals", icon: ClipboardCheck },
|
||||
{ title: "Audit Log", code: "settings.audit-logs", href: "/dashboard/settings/audit-logs", icon: ScrollText },
|
||||
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
@@ -439,32 +443,19 @@ export function AppSidebar() {
|
||||
// While /auth/me hasn't resolved yet, show nothing rather than briefly
|
||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||
// is visible if its own code is granted, or (for parents) if any child is.
|
||||
//
|
||||
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
|
||||
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
|
||||
// or their children server-side (stock's children specifically have no SubNavItem rows at
|
||||
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
|
||||
// are granted the permission properly (Settings → Roles → Sidebar permissions) or a backend
|
||||
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||
// anything server-side.
|
||||
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
|
||||
|
||||
// Every section now has seeded nav rows (HRM and Sales Returns were the last gaps), so there is no
|
||||
// exemption list any more: what you see is exactly what your role grants (an administrator holds every code).
|
||||
const visibleItems = loading
|
||||
? []
|
||||
: navItems
|
||||
.filter(
|
||||
(item) =>
|
||||
bypassCodes.has(item.code) ||
|
||||
navCodes.includes(item.code) ||
|
||||
item.children?.some((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
|
||||
)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: (bypassCodes.has(item.code)
|
||||
? item.children
|
||||
: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac)))
|
||||
),
|
||||
children: item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))),
|
||||
}))
|
||||
|
||||
// Close mobile menu on route change
|
||||
|
||||
@@ -49,6 +49,7 @@ const PROCUREMENT_TITLES: Record<string, string> = {
|
||||
"/dashboard/procurement/rfqs/new": "New RFQ",
|
||||
"/dashboard/procurement/purchase-orders": "Purchase Orders",
|
||||
"/dashboard/procurement/purchase-orders/new": "New Purchase Order",
|
||||
"/dashboard/procurement/purchase-orders/approvals": "PO Approval Queue",
|
||||
"/dashboard/procurement/purchase-returns": "Purchase Returns",
|
||||
"/dashboard/procurement/purchase-returns/new": "New Purchase Return",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { CheckCircle2, Landmark, Wallet } from "lucide-react"
|
||||
|
||||
import { openingBalancesApi } from "@/lib/api/opening-balances"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate, todayIso } from "@/lib/format"
|
||||
import { CashBankOpeningState, OpeningCashBankAccount } from "@/types/opening-balances"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldDescription, FieldError, 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"
|
||||
|
||||
/** Amounts with at most 2 decimals; blank means "nothing to enter for this account". */
|
||||
function parseAmount(raw: string): { value: number | null; error?: string } {
|
||||
const text = raw.trim()
|
||||
if (text === "") return { value: null }
|
||||
const n = Number(text)
|
||||
if (!Number.isFinite(n)) return { value: null, error: "Enter a number" }
|
||||
if (Math.round(n * 100) / 100 !== n) return { value: null, error: "At most 2 decimals" }
|
||||
return { value: n }
|
||||
}
|
||||
|
||||
function AccountIcon({ type }: { type: string }) {
|
||||
const Icon = type === "Bank" ? Landmark : Wallet
|
||||
return <Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening balances — cash and bank. The amounts become ONE journal in the General Ledger (a debit to each
|
||||
* account, the offsetting credit to "Opening Balance Equity"), posted once. Once posted this panel shows the
|
||||
* record instead of the form; later corrections are made with an ordinary journal entry.
|
||||
*/
|
||||
export function OpeningCashBankPanel() {
|
||||
const [state, setState] = useState<CashBankOpeningState | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [date, setDate] = useState(todayIso())
|
||||
const [amounts, setAmounts] = useState<Record<string, string>>({})
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [posting, setPosting] = useState(false)
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
openingBalancesApi
|
||||
.cashBank()
|
||||
.then(setState)
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
// Accounts in another currency are shown but cannot take an amount: the journal is in the base currency.
|
||||
const usable = (a: OpeningCashBankAccount) => !state || a.currencyCode.toUpperCase() === state.baseCurrency.toUpperCase()
|
||||
|
||||
const total = useMemo(() => {
|
||||
let sum = 0
|
||||
for (const a of state?.accounts ?? []) {
|
||||
const { value } = parseAmount(amounts[a.glAccountCode] ?? "")
|
||||
if (value !== null && usable(a)) sum += value
|
||||
}
|
||||
return Math.round(sum * 100) / 100
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [amounts, state])
|
||||
|
||||
function validate(): { lines: { glAccountCode: string; amount: number }[]; ok: boolean } {
|
||||
const next: Record<string, string> = {}
|
||||
const lines: { glAccountCode: string; amount: number }[] = []
|
||||
for (const a of state?.accounts ?? []) {
|
||||
if (!usable(a)) continue
|
||||
const { value, error: err } = parseAmount(amounts[a.glAccountCode] ?? "")
|
||||
if (err) next[a.glAccountCode] = err
|
||||
else if (value !== null && value !== 0) lines.push({ glAccountCode: a.glAccountCode, amount: value })
|
||||
}
|
||||
if (!date) next.date = "Choose the opening date"
|
||||
else if (date > todayIso()) next.date = "The opening date cannot be in the future"
|
||||
if (lines.length === 0 && Object.keys(next).length === 0) next.lines = "Enter an amount for at least one account"
|
||||
setErrors(next)
|
||||
return { lines, ok: Object.keys(next).length === 0 }
|
||||
}
|
||||
|
||||
async function post() {
|
||||
const { lines, ok } = validate()
|
||||
if (!ok) return
|
||||
setPosting(true)
|
||||
try {
|
||||
const run = await openingBalancesApi.postCashBank({ cutoverDate: date, lines })
|
||||
toast.success("Opening balances posted", `Journal ${run.glJournalNo ?? ""} — ${run.currency} ${formatAmount(run.totalAmount)}`)
|
||||
load()
|
||||
} catch (err) {
|
||||
const code = (err as { code?: string })?.code
|
||||
toast.error("Could not post the opening balances", errorMessage(err))
|
||||
// Already done by someone else: show the posted record instead of an empty form.
|
||||
if (code === "OPENING_BALANCE_ALREADY_POSTED") load()
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
}
|
||||
if (!state) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
const posted = state.posted
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-base text-muted-foreground">
|
||||
Enter what each cash and bank account held when you started using the system. It is posted once, as a single
|
||||
General Ledger journal against <span className="font-medium text-foreground">Opening Balance Equity</span>.
|
||||
</p>
|
||||
|
||||
{posted ? (
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="mt-0.5 size-6 shrink-0 text-success" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Cash and bank opening balances are posted</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
As at {formatReportDate(posted.cutoverDate)} · journal <span className="font-medium text-foreground">{posted.glJournalNo ?? "—"}</span> ·
|
||||
posted {new Date(posted.postedAt).toLocaleString()}. To change an amount, post a correcting journal entry in the General Ledger.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Account</TableHead>
|
||||
<TableHead>Code</TableHead>
|
||||
<TableHead className="text-right">Opening balance ({posted.currency})</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{posted.lines.map((l) => (
|
||||
<TableRow key={l.accountCode}>
|
||||
<TableCell className="font-medium text-foreground">{l.accountName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{l.accountCode}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(l.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
|
||||
<TableCell colSpan={2}>Credited to Opening Balance Equity</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(posted.totalAmount)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<Field data-invalid={!!errors.date} className="sm:max-w-xs">
|
||||
<FieldLabel htmlFor="opening-date" required>Opening date</FieldLabel>
|
||||
<Input
|
||||
id="opening-date"
|
||||
type="date"
|
||||
max={todayIso()}
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value)
|
||||
setErrors((p) => ({ ...p, date: "" }))
|
||||
}}
|
||||
aria-invalid={!!errors.date}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.date ? { message: errors.date } : undefined]} />
|
||||
<FieldDescription>
|
||||
Must fall in an open accounting period. Reports that start after this date show these amounts as opening
|
||||
balances, so use the day you started — or the first day of the financial year.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
{state.accounts.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed p-8 text-center text-base text-muted-foreground">
|
||||
There are no cash or bank accounts yet. Create them under Accounts → Cash / Bank Accounts first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Account</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">Code</TableHead>
|
||||
<TableHead className="w-56 text-right">Opening balance ({state.baseCurrency})</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{state.accounts.map((a) => {
|
||||
const ok = usable(a)
|
||||
const err = errors[a.glAccountCode]
|
||||
return (
|
||||
<TableRow key={a.glAccountCode} className="align-top">
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<AccountIcon type={a.accountType} />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">{a.accountName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{a.accountType}
|
||||
{a.bankName ? ` · ${a.bankName}` : ""}
|
||||
{a.accountNumber ? ` · ${a.accountNumber}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">{a.glAccountCode}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{ok ? (
|
||||
<>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={amounts[a.glAccountCode] ?? ""}
|
||||
onChange={(e) => {
|
||||
setAmounts((p) => ({ ...p, [a.glAccountCode]: e.target.value }))
|
||||
setErrors((p) => ({ ...p, [a.glAccountCode]: "", lines: "" }))
|
||||
}}
|
||||
placeholder="0.00"
|
||||
aria-label={`Opening balance for ${a.accountName}`}
|
||||
aria-invalid={!!err}
|
||||
className="h-11 text-right text-base"
|
||||
/>
|
||||
<FieldError errors={[err ? { message: err } : undefined]} />
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{a.currencyCode} account — not supported here</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
|
||||
<TableCell colSpan={2} className="hidden sm:table-cell">Credited to Opening Balance Equity</TableCell>
|
||||
<TableCell className="sm:hidden">Opening Balance Equity (credit)</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(total)}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.lines && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{errors.lines}</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A negative amount records an overdraft (a credit balance). Leave an account blank if it opened at zero.
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button size="lg" disabled={posting || state.accounts.length === 0} />}>
|
||||
{posting ? "Posting…" : "Post opening balances"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="warning"
|
||||
title="Post the opening balances?"
|
||||
description={`This posts one General Ledger journal dated ${date || "—"} totalling ${state.baseCurrency} ${formatAmount(total)}. It can only be done once; any later change needs a correcting journal entry.`}
|
||||
confirmLabel="Post"
|
||||
onConfirm={post}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { ClipboardPaste, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { openingBalancesApi } from "@/lib/api/opening-balances"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate, todayIso } from "@/lib/format"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
import { OpeningStockRun, OpeningStockRunSummary } from "@/types/opening-balances"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Combobox, ComboboxOption } from "@/components/ui/combobox"
|
||||
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DraftLine {
|
||||
key: string
|
||||
itemId: number | null
|
||||
qty: string
|
||||
unitCost: string
|
||||
}
|
||||
|
||||
let keySeq = 0
|
||||
const newLine = (patch: Partial<DraftLine> = {}): DraftLine => ({ key: `ol-${++keySeq}`, itemId: null, qty: "", unitCost: "", ...patch })
|
||||
|
||||
/** A positive number with at most `dp` decimals; null otherwise. */
|
||||
function num(raw: string, dp: number, allowZero: boolean): number | null {
|
||||
const text = raw.trim()
|
||||
if (text === "") return null
|
||||
const n = Number(text)
|
||||
if (!Number.isFinite(n) || n < 0 || (!allowZero && n === 0)) return null
|
||||
const f = 10 ** dp
|
||||
return Math.round(n * f) / f === n ? n : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening stock: what is already on the shelf when the system starts. One warehouse per run (load them one at a
|
||||
* time); each line becomes a FIFO layer at the unit cost entered, and ONE journal debits Inventory against
|
||||
* Opening Balance Equity. An item that already has stock movements in the warehouse is refused by the server.
|
||||
* Only stocked, untracked items are offered — serial/batch items need per-unit identities.
|
||||
*/
|
||||
export function OpeningStockPanel() {
|
||||
const [items, setItems] = useState<ItemListItem[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
|
||||
const [runs, setRuns] = useState<OpeningStockRunSummary[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [date, setDate] = useState(todayIso())
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [lines, setLines] = useState<DraftLine[]>([newLine()])
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [posting, setPosting] = useState(false)
|
||||
|
||||
const [pasteOpen, setPasteOpen] = useState(false)
|
||||
const [pasteText, setPasteText] = useState("")
|
||||
const [pasteNotes, setPasteNotes] = useState<string[]>([])
|
||||
|
||||
const [openRun, setOpenRun] = useState<OpeningStockRun | null>(null)
|
||||
const [openRunLoading, setOpenRunLoading] = useState(false)
|
||||
|
||||
function loadRuns() {
|
||||
openingBalancesApi.stockRuns().then(setRuns).catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRuns()
|
||||
warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([]))
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const all: ItemListItem[] = []
|
||||
for (let page = 1; page <= 25; page++) {
|
||||
const res = await itemsApi.list({ status: "Active", page, pageSize: 200 })
|
||||
all.push(...res.items)
|
||||
if (page >= res.pagination.totalPages) break
|
||||
}
|
||||
if (!cancelled) setItems(all)
|
||||
})().catch((err) => {
|
||||
if (!cancelled) {
|
||||
setItems([])
|
||||
toast.error("Could not load items", errorMessage(err))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only what the server accepts: stocked items with no serial/batch tracking.
|
||||
const eligible = useMemo(() => (items ?? []).filter((i) => i.stockNature === "Stocked" && i.trackingMode === "None"), [items])
|
||||
const options = useMemo<ComboboxOption<number>[]>(
|
||||
() => eligible.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })).sort((a, b) => a.label.localeCompare(b.label)),
|
||||
[eligible],
|
||||
)
|
||||
|
||||
const total = useMemo(
|
||||
() =>
|
||||
Math.round(
|
||||
lines.reduce((sum, l) => {
|
||||
const q = num(l.qty, 4, false)
|
||||
const c = num(l.unitCost, 4, true)
|
||||
return sum + (q !== null && c !== null ? q * c : 0)
|
||||
}, 0) * 10000,
|
||||
) / 10000,
|
||||
[lines],
|
||||
)
|
||||
|
||||
function updateLine(key: string, patch: Partial<DraftLine>) {
|
||||
setLines((prev) => prev.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
setErrors((prev) => ({ ...prev, [key]: "", general: "" }))
|
||||
}
|
||||
|
||||
/** "SKU qty cost" per row, tab- or comma-separated — what copying three columns out of a spreadsheet gives. */
|
||||
function applyPaste() {
|
||||
const notes: string[] = []
|
||||
const next: DraftLine[] = []
|
||||
const bySku = new Map(eligible.map((i) => [i.sku.trim().toLowerCase(), i]))
|
||||
pasteText.split(/\r?\n/).forEach((row, idx) => {
|
||||
if (!row.trim()) return
|
||||
const cells = row.split(/\t|,/).map((c) => c.trim())
|
||||
if (idx === 0 && Number.isNaN(Number(cells[1]))) return // a header row
|
||||
const [sku, qty, cost] = cells
|
||||
const item = bySku.get((sku ?? "").toLowerCase())
|
||||
if (!item) notes.push(`Row ${idx + 1}: "${sku}" is not an active, stocked, untracked item — skipped.`)
|
||||
else next.push(newLine({ itemId: item.itemId, qty: qty ?? "", unitCost: cost ?? "" }))
|
||||
})
|
||||
if (next.length > 0) setLines((prev) => [...prev.filter((l) => l.itemId !== null || l.qty || l.unitCost), ...next])
|
||||
setPasteNotes(notes)
|
||||
if (notes.length === 0) {
|
||||
setPasteOpen(false)
|
||||
setPasteText("")
|
||||
}
|
||||
toast.success("Rows added", `${next.length} line${next.length === 1 ? "" : "s"} imported${notes.length ? `, ${notes.length} skipped` : ""}.`)
|
||||
}
|
||||
|
||||
function validate() {
|
||||
const next: Record<string, string> = {}
|
||||
const payload: { itemId: number; warehouseId: number; qty: number; unitCost: number }[] = []
|
||||
const used = new Set<number>()
|
||||
for (const l of lines) {
|
||||
if (l.itemId === null && !l.qty && !l.unitCost) continue // an untouched blank row
|
||||
const q = num(l.qty, 4, false)
|
||||
const c = num(l.unitCost, 4, true)
|
||||
if (l.itemId === null) next[l.key] = "Select an item"
|
||||
else if (used.has(l.itemId)) next[l.key] = "This item is already on another line"
|
||||
else if (q === null) next[l.key] = "Quantity must be more than 0 (up to 4 decimals)"
|
||||
else if (c === null) next[l.key] = "Unit cost must be 0 or more (up to 4 decimals)"
|
||||
else {
|
||||
used.add(l.itemId)
|
||||
payload.push({ itemId: l.itemId, warehouseId: warehouseId ?? 0, qty: q, unitCost: c })
|
||||
}
|
||||
}
|
||||
if (warehouseId === null) next.warehouse = "Choose the warehouse"
|
||||
if (!date) next.date = "Choose the opening date"
|
||||
else if (date > todayIso()) next.date = "The opening date cannot be in the future"
|
||||
if (payload.length === 0 && Object.keys(next).length === 0) next.general = "Add at least one item with a quantity"
|
||||
setErrors(next)
|
||||
return { payload, ok: Object.keys(next).length === 0 }
|
||||
}
|
||||
|
||||
async function post() {
|
||||
const { payload, ok } = validate()
|
||||
if (!ok) return
|
||||
setPosting(true)
|
||||
try {
|
||||
const run = await openingBalancesApi.postStock({ cutoverDate: date, lines: payload })
|
||||
toast.success(
|
||||
"Opening stock posted",
|
||||
`${run.lineCount} line${run.lineCount === 1 ? "" : "s"} · ${formatAmount(run.totalValue)}${run.glJournalNo ? ` · ${run.glJournalNo}` : ""}`,
|
||||
)
|
||||
setLines([newLine()])
|
||||
setOpenRun(run)
|
||||
loadRuns()
|
||||
} catch (err) {
|
||||
toast.error("Could not post the opening stock", errorMessage(err))
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function showRun(id: number) {
|
||||
setOpenRunLoading(true)
|
||||
try {
|
||||
setOpenRun(await openingBalancesApi.stockRun(id))
|
||||
} catch (err) {
|
||||
toast.error("Could not load that run", errorMessage(err))
|
||||
} finally {
|
||||
setOpenRunLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
if (items === null || runs === null) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<p className="text-base text-muted-foreground">
|
||||
Enter the stock you already have on the shelf, one warehouse at a time. Each line becomes stock at the unit cost you give, and one
|
||||
General Ledger journal debits Inventory against <span className="font-medium text-foreground">Opening Balance Equity</span>.
|
||||
An item can be opened once per warehouse — after that, use a stock adjustment.
|
||||
</p>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 rounded-2xl border bg-card p-5 shadow-sm sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.date}>
|
||||
<FieldLabel htmlFor="ostock-date" required>Opening date</FieldLabel>
|
||||
<Input
|
||||
id="ostock-date"
|
||||
type="date"
|
||||
max={todayIso()}
|
||||
value={date}
|
||||
onChange={(e) => {
|
||||
setDate(e.target.value)
|
||||
setErrors((p) => ({ ...p, date: "" }))
|
||||
}}
|
||||
aria-invalid={!!errors.date}
|
||||
className="h-11 text-base"
|
||||
/>
|
||||
<FieldError errors={[errors.date ? { message: errors.date } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.warehouse}>
|
||||
<FieldLabel required>Warehouse</FieldLabel>
|
||||
<Select<number | null>
|
||||
value={warehouseId}
|
||||
onValueChange={(v) => {
|
||||
setWarehouseId(v)
|
||||
setErrors((p) => ({ ...p, warehouse: "" }))
|
||||
}}
|
||||
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.warehouse}>
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.warehouse ? { message: errors.warehouse } : undefined]} />
|
||||
<FieldDescription>Quantities are counted in each item's base unit (bottle, can, pack…).</FieldDescription>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold text-foreground">Items</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setPasteOpen((o) => !o)}>
|
||||
<ClipboardPaste className="size-5" />
|
||||
Paste from spreadsheet
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setLines((prev) => [...prev, newLine()])}>
|
||||
<Plus className="size-5" />
|
||||
Add line
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pasteOpen && (
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-dashed p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste rows of <span className="font-medium text-foreground">SKU, quantity, unit cost</span> (tab- or comma-separated — three columns
|
||||
copied from a spreadsheet work). A header row is skipped.
|
||||
</p>
|
||||
<Textarea
|
||||
value={pasteText}
|
||||
onChange={(e) => setPasteText(e.target.value)}
|
||||
placeholder={"CLA-500ML\t240\t95.50\nCLA-1L\t120\t160"}
|
||||
rows={6}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
{pasteNotes.length > 0 && (
|
||||
<ul className="list-disc pl-5 text-sm text-destructive">
|
||||
{pasteNotes.slice(0, 8).map((n) => <li key={n}>{n}</li>)}
|
||||
{pasteNotes.length > 8 && <li>…and {pasteNotes.length - 8} more.</li>}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" onClick={applyPaste} disabled={!pasteText.trim()}>Add rows</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => { setPasteOpen(false); setPasteNotes([]) }}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="min-w-[44rem] text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="w-[40%]">Item</TableHead>
|
||||
<TableHead className="w-32 text-right">Quantity</TableHead>
|
||||
<TableHead className="w-36 text-right">Unit cost</TableHead>
|
||||
<TableHead className="w-36 text-right">Value</TableHead>
|
||||
<TableHead className="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{lines.map((l) => {
|
||||
const q = num(l.qty, 4, false)
|
||||
const c = num(l.unitCost, 4, true)
|
||||
return (
|
||||
<TableRow key={l.key} className="align-top">
|
||||
<TableCell>
|
||||
<Combobox<number>
|
||||
items={options}
|
||||
value={l.itemId}
|
||||
onValueChange={(v) => updateLine(l.key, { itemId: v })}
|
||||
placeholder="Type SKU or name"
|
||||
emptyText="No matching stocked, untracked item."
|
||||
className="h-11 text-base"
|
||||
aria-invalid={!!errors[l.key]}
|
||||
/>
|
||||
<FieldError errors={[errors[l.key] ? { message: errors[l.key] } : undefined]} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number" min="0" step="any" inputMode="decimal" value={l.qty}
|
||||
onChange={(e) => updateLine(l.key, { qty: e.target.value })}
|
||||
aria-label="Quantity" className="h-11 text-right text-base"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number" min="0" step="any" inputMode="decimal" value={l.unitCost}
|
||||
onChange={(e) => updateLine(l.key, { unitCost: e.target.value })}
|
||||
aria-label="Unit cost" className="h-11 text-right text-base"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="pt-3.5 text-right text-muted-foreground">{q !== null && c !== null ? formatAmount(q * c) : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
type="button" variant="ghost" size="icon" aria-label="Remove line"
|
||||
onClick={() => setLines((prev) => (prev.length > 1 ? prev.filter((x) => x.key !== l.key) : [newLine()]))}
|
||||
>
|
||||
<Trash2 className="size-5" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
<TableRow className="bg-muted/30 font-semibold hover:bg-muted/30">
|
||||
<TableCell colSpan={3}>Debited to Inventory / credited to Opening Balance Equity</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(total)}</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{errors.general && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{errors.general}</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger render={<Button size="lg" disabled={posting} />}>
|
||||
{posting ? "Posting…" : "Post opening stock"}
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent
|
||||
variant="warning"
|
||||
title="Post this opening stock?"
|
||||
description={`This adds the stock to ${warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse"} dated ${date || "—"} and posts one journal for ${formatAmount(total)}. An item can't be opened twice in a warehouse, so check the quantities and costs.`}
|
||||
confirmLabel="Post"
|
||||
onConfirm={post}
|
||||
/>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Posted runs</h2>
|
||||
{runs.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Nothing posted yet.</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>As at</TableHead>
|
||||
<TableHead>Journal</TableHead>
|
||||
<TableHead className="text-right">Lines</TableHead>
|
||||
<TableHead className="text-right">Value</TableHead>
|
||||
<TableHead className="w-24" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{runs.map((r) => (
|
||||
<TableRow key={r.openingBalanceRunId}>
|
||||
<TableCell>{formatReportDate(r.cutoverDate)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{r.glJournalNo ?? "— (zero value)"}</TableCell>
|
||||
<TableCell className="text-right">{r.lineCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(r.totalValue)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button type="button" variant="ghost" size="sm" disabled={openRunLoading} onClick={() => showRun(r.openingBalanceRunId)}>View</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openRun && (
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Run as at {formatReportDate(openRun.cutoverDate)} · {openRun.glJournalNo ?? "no journal"}
|
||||
</p>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOpenRun(null)}>Close</Button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead>Item</TableHead>
|
||||
<TableHead>Warehouse</TableHead>
|
||||
<TableHead className="text-right">Qty</TableHead>
|
||||
<TableHead className="text-right">Unit cost</TableHead>
|
||||
<TableHead className="text-right">Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{openRun.lines.map((l) => (
|
||||
<TableRow key={`${l.itemId}-${l.warehouseId}`}>
|
||||
<TableCell>{l.sku} — {l.itemName}</TableCell>
|
||||
<TableCell>{l.warehouseCode}</TableCell>
|
||||
<TableCell className="text-right">{l.qty}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(l.unitCost)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(l.value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { Lock, RefreshCw, ShieldOff } from "lucide-react"
|
||||
|
||||
import { requiredCodesFor } from "@/lib/nav-routes"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
/**
|
||||
* Two doors. First: the page is shown only to people who hold a role. Someone who signs in with a valid login but has no usable role
|
||||
* (none assigned and no default role configured, or their role was deactivated) — or whose account is switched
|
||||
* off — gets a plain explanation instead of a menu full of "Forbidden" screens. "Check again" re-reads their role
|
||||
* from the server, so access an administrator just granted appears without signing out.
|
||||
*
|
||||
* Second: a person with a role still only opens the pages that role grants, even by typing the address
|
||||
* (lib/nav-routes.ts maps each page to its permission). The API enforces the same permissions on every call.
|
||||
*/
|
||||
export function AccessGate({ children }: { children: React.ReactNode }) {
|
||||
const { loading, hasRole, accountBlocked, refresh, can, roleName } = useAuth()
|
||||
const pathname = usePathname() || "/"
|
||||
const [checking, setChecking] = useState(false)
|
||||
|
||||
if (loading) return <>{children}</>
|
||||
|
||||
if (hasRole) {
|
||||
const required = requiredCodesFor(pathname)
|
||||
if (required === null || required.some((code) => can(code))) return <>{children}</>
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10 text-destructive">
|
||||
<ShieldOff className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">You don't have access to this page</h1>
|
||||
<p className="max-w-md text-base text-muted-foreground">
|
||||
{roleName ? `Your role (${roleName}) doesn't include this area.` : "Your role doesn't include this area."} If you need it,
|
||||
ask an administrator to add it under Settings → Roles.
|
||||
</p>
|
||||
<Link href="/dashboard" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Back to the dashboard
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function checkAgain() {
|
||||
setChecking(true)
|
||||
try {
|
||||
await refresh()
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-warning/10 text-warning">
|
||||
<Lock className="size-7" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-foreground">
|
||||
{accountBlocked ? "Your account is switched off" : "Waiting for access"}
|
||||
</h1>
|
||||
<p className="max-w-md text-base text-muted-foreground">
|
||||
{accountBlocked
|
||||
? "An administrator has deactivated this account. Please contact them if you think this is a mistake."
|
||||
: "You're signed in, but no role has been assigned to your account yet. Ask an administrator to give you one (Settings → Users), then check again."}
|
||||
</p>
|
||||
{!accountBlocked && (
|
||||
<Button variant="outline" size="lg" onClick={checkAgain} disabled={checking}>
|
||||
<RefreshCw className={checking ? "size-5 animate-spin" : "size-5"} />
|
||||
{checking ? "Checking…" : "Check again"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
"use client"
|
||||
|
||||
// Authoritative session context: fetches GET /auth/me once per mount and exposes
|
||||
// the current role + permitted sidebar nav codes. Replaces trusting the stale,
|
||||
// client-only `roleId` cached by lib/auth-session.ts for anything access-related
|
||||
// (that cache remains display-only, e.g. for the header's user name).
|
||||
import { createContext, useContext, useEffect, useState } from "react"
|
||||
// Authoritative session context: fetches GET /auth/me and exposes the current role and what it may do.
|
||||
// The role lives in the ERP (users.RoleId) and is read on every request, so this is refetched whenever the
|
||||
// window regains focus (and on demand via `refresh`) — a role or permission change made by an administrator
|
||||
// shows up without signing out. Replaces trusting the stale, client-only `roleId` cached by
|
||||
// lib/auth-session.ts for anything access-related (that cache remains display-only, e.g. the header's name).
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"
|
||||
|
||||
import { authApi } from "@/lib/api/auth"
|
||||
import { MeResponse } from "@/types/rbac"
|
||||
import { ApiError, FORBIDDEN_EVENT } from "@/lib/api-client"
|
||||
import { MeResponse, RolePermissionAction } from "@/types/rbac"
|
||||
|
||||
interface ActiveBranch {
|
||||
id: number | null
|
||||
@@ -19,6 +21,9 @@ interface ActiveBranch {
|
||||
* display/filter convenience — never trusted server-side. */
|
||||
const ACTIVE_BRANCH_KEY = "erpcore.activeBranch"
|
||||
|
||||
/** Don't re-ask the server more often than this when the window keeps regaining focus. */
|
||||
const FOCUS_REFRESH_MIN_MS = 15_000
|
||||
|
||||
function readStoredActiveBranch(): ActiveBranch | null {
|
||||
if (typeof window === "undefined") return null
|
||||
const raw = window.localStorage.getItem(ACTIVE_BRANCH_KEY)
|
||||
@@ -31,12 +36,28 @@ function readStoredActiveBranch(): ActiveBranch | null {
|
||||
}
|
||||
}
|
||||
|
||||
/** The names of the actions a permission can grant (Approve, Delete, …). */
|
||||
export type PermissionActionName = keyof typeof RolePermissionAction
|
||||
|
||||
interface AuthContextValue {
|
||||
roleId: number | null
|
||||
roleCode: string | null
|
||||
roleName: string | null
|
||||
navCodes: string[]
|
||||
/** True for the Admin role — the only role that may pick any branch (others are locked to their own). */
|
||||
/** True when the person has a usable role. False means "waiting for access": no role assigned and no default, or an inactive role. */
|
||||
hasRole: boolean
|
||||
/** True for the administrator role — the only role that may pick any branch (others are locked to their own). */
|
||||
isAdmin: boolean
|
||||
/**
|
||||
* Whether the role may reach `navCode` (a module, or a screen within one) and — when `action` is given — holds that
|
||||
* action on exactly that code. Administrators may do everything. Hide or disable UI with this; the server
|
||||
* enforces the same rules, so this is a convenience, never the protection.
|
||||
*/
|
||||
can: (navCode: string, action?: PermissionActionName) => boolean
|
||||
/** Re-reads the role and permissions from the server (after changing your own role, say). */
|
||||
refresh: () => Promise<void>
|
||||
/** True when the account itself is switched off (the server answered 403 to /auth/me). */
|
||||
accountBlocked: boolean
|
||||
/** The caller's own branch, via their linked Employee record — null if unlinked. */
|
||||
branchId: number | null
|
||||
branchName: string | null
|
||||
@@ -67,10 +88,15 @@ interface AuthContextValue {
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue>({
|
||||
roleId: null,
|
||||
roleCode: null,
|
||||
roleName: null,
|
||||
navCodes: [],
|
||||
hasRole: false,
|
||||
isAdmin: false,
|
||||
can: () => false,
|
||||
refresh: async () => {},
|
||||
accountBlocked: false,
|
||||
branchId: null,
|
||||
branchName: null,
|
||||
activeBranchId: null,
|
||||
@@ -81,27 +107,50 @@ const AuthContext = createContext<AuthContextValue>({
|
||||
loading: true,
|
||||
})
|
||||
|
||||
const NO_ACCESS: MeResponse = {
|
||||
roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null,
|
||||
branchingEnabled: true, warehouseBranchingEnabled: true, roleId: null, hasRole: false, permissions: {},
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [accountBlocked, setAccountBlocked] = useState(false)
|
||||
const [activeBranch, setActiveBranchState] = useState<ActiveBranch | null>(null)
|
||||
const lastFetch = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
authApi
|
||||
.me()
|
||||
.then((res) => {
|
||||
if (!cancelled) setMe(res)
|
||||
})
|
||||
.catch(() => {
|
||||
// Unauthenticated/unreachable — fall back to "no permissions" rather than
|
||||
// crash the shell; proxy.ts already redirects unauthenticated users to /login.
|
||||
if (!cancelled) setMe({ roleCode: null, roleName: null, navCodes: [], isAdmin: false, branchId: null, branchName: null, branchingEnabled: true, warehouseBranchingEnabled: true })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
const load = useCallback(async () => {
|
||||
lastFetch.current = Date.now()
|
||||
try {
|
||||
const res = await authApi.me()
|
||||
setMe(res)
|
||||
setAccountBlocked(false)
|
||||
} catch (err) {
|
||||
// 403 = the ERP account is switched off; anything else (unauthenticated/unreachable) falls back to
|
||||
// "no permissions" rather than crashing the shell — proxy.ts already redirects unauthenticated users to /login.
|
||||
setAccountBlocked(err instanceof ApiError && err.status === 403)
|
||||
setMe((prev) => prev ?? NO_ACCESS)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// The role is read from the ERP on every request, so keep the UI in step with it: refetch when the person comes
|
||||
// back to the tab (rate-limited), which picks up a role or permission change an administrator just made.
|
||||
useEffect(() => {
|
||||
function onFocus() {
|
||||
if (Date.now() - lastFetch.current >= FOCUS_REFRESH_MIN_MS) void load()
|
||||
}
|
||||
// A 403 from any call means what this tab believes the role can do may be stale: re-read it (same rate limit).
|
||||
window.addEventListener("focus", onFocus)
|
||||
window.addEventListener(FORBIDDEN_EVENT, onFocus)
|
||||
return () => {
|
||||
window.removeEventListener("focus", onFocus)
|
||||
window.removeEventListener(FORBIDDEN_EVENT, onFocus)
|
||||
}
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (me === null) return
|
||||
// Non-admins are always pinned to their own branch — never their own past picks.
|
||||
@@ -115,13 +164,33 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
if (typeof window !== "undefined") window.localStorage.setItem(ACTIVE_BRANCH_KEY, JSON.stringify(next))
|
||||
}
|
||||
|
||||
const can = useCallback(
|
||||
(navCode: string, action?: PermissionActionName): boolean => {
|
||||
if (!me) return false
|
||||
if (me.isAdmin) return true
|
||||
const permissions = me.permissions ?? {}
|
||||
if (action) {
|
||||
const granted = permissions[navCode]
|
||||
return granted !== undefined && (granted & RolePermissionAction[action]) !== 0
|
||||
}
|
||||
// Reaching a module: the code itself, or any screen inside it ("procurement" matches "procurement.vendors").
|
||||
return navCode in permissions || Object.keys(permissions).some((c) => c.startsWith(navCode + "."))
|
||||
},
|
||||
[me],
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
roleId: me?.roleId ?? null,
|
||||
roleCode: me?.roleCode ?? null,
|
||||
roleName: me?.roleName ?? null,
|
||||
navCodes: me?.navCodes ?? [],
|
||||
hasRole: me?.hasRole ?? false,
|
||||
isAdmin: me?.isAdmin ?? false,
|
||||
can,
|
||||
refresh: load,
|
||||
accountBlocked,
|
||||
branchId: me?.branchId ?? null,
|
||||
branchName: me?.branchName ?? null,
|
||||
activeBranchId: activeBranch?.id ?? null,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { useCustomerConfig } from "@/hooks/use-customer-config"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { Customer, CustomerType } from "@/types/customers"
|
||||
@@ -28,6 +29,8 @@ export function EditCustomerDialog({
|
||||
onClose: () => void
|
||||
onSaved: (customer: Customer) => void
|
||||
}) {
|
||||
// A field switched off in Sales Configuration is hidden; its stored value is sent back unchanged.
|
||||
const { fields } = useCustomerConfig()
|
||||
const [customer, setCustomer] = useState<Customer | null>(null)
|
||||
const [etag, setEtag] = useState<string | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
@@ -194,48 +197,70 @@ export function EditCustomerDialog({
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="ec-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-email">Email (optional)</FieldLabel>
|
||||
<Input id="ec-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-addr1">Address line 1 (optional)</FieldLabel>
|
||||
<Input id="ec-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-addr2">Address line 2 (optional)</FieldLabel>
|
||||
<Input id="ec-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-city">City (optional)</FieldLabel>
|
||||
<Input id="ec-city" value={city} onChange={(e) => setCity(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-country">Country (optional)</FieldLabel>
|
||||
<Input id="ec-country" value={country} onChange={(e) => setCountry(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{(fields.phone || fields.email) && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{fields.phone && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-phone">Phone (optional)</FieldLabel>
|
||||
<Input id="ec-phone" value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="0771234567" />
|
||||
</Field>
|
||||
)}
|
||||
{fields.email && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-email">Email (optional)</FieldLabel>
|
||||
<Input id="ec-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="customer@example.com" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{fields.address && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-addr1">Address line 1 (optional)</FieldLabel>
|
||||
<Input id="ec-addr1" value={addressLine1} onChange={(e) => setAddressLine1(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-addr2">Address line 2 (optional)</FieldLabel>
|
||||
<Input id="ec-addr2" value={addressLine2} onChange={(e) => setAddressLine2(e.target.value)} />
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
{(fields.city || fields.country) && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{fields.city && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-city">City (optional)</FieldLabel>
|
||||
<Input id="ec-city" value={city} onChange={(e) => setCity(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{fields.country && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-country">Country (optional)</FieldLabel>
|
||||
<Input id="ec-country" value={country} onChange={(e) => setCountry(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-tax">Tax registration (optional)</FieldLabel>
|
||||
<Input id="ec-tax" value={taxRegistrationNo} onChange={(e) => setTaxRegistrationNo(e.target.value)} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||
<Input id="ec-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-credit-days">Credit days</FieldLabel>
|
||||
<Input id="ec-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
{(fields.creditLimit || fields.creditDays) && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{fields.creditLimit && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-credit-limit">Credit limit (LKR)</FieldLabel>
|
||||
<Input id="ec-credit-limit" type="number" min="0" step="0.01" placeholder="0" value={creditLimit} onChange={(e) => setCreditLimit(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
{fields.creditDays && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ec-credit-days">Credit days</FieldLabel>
|
||||
<Input id="ec-credit-days" type="number" min="0" step="1" placeholder="0" value={creditDays} onChange={(e) => setCreditDays(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ShoppingCart, Truck } from "lucide-react"
|
||||
|
||||
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Branch } from "@/types/hrm"
|
||||
import { PurchaseOrderStatus, PurchaseOrderSummary } from "@/types/procurement"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { PoStatusBadge } from "@/components/procurement/status-badges"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DateRangeFilter, DateRangeValue, dateRangeBounds } from "@/components/ui/date-range-filter"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = PurchaseOrderStatus | "All"
|
||||
type SortKey = "created" | "vendor" | "total" | "status"
|
||||
type SortOrder = "asc" | "desc"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All statuses" },
|
||||
{ value: "Draft", label: "Draft" },
|
||||
{ value: "PendingApproval", label: "Pending approval" },
|
||||
{ value: "Approved", label: "Approved" },
|
||||
{ value: "PartiallyReceived", label: "Partially received" },
|
||||
{ value: "FullyReceived", label: "Fully received" },
|
||||
{ value: "Closed", label: "Closed" },
|
||||
{ value: "Rejected", label: "Rejected" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
]
|
||||
|
||||
function SortableHeader({ label, active, order, onClick }: { label: string; active: boolean; order: SortOrder; onClick: () => void }) {
|
||||
return (
|
||||
<button type="button" className="flex items-center gap-1 hover:text-foreground" onClick={onClick}>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export interface PoRowContext {
|
||||
/** Re-reads the current page (after a delete, submit, approve, …). */
|
||||
reload: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The purchase-order table, shared by Purchase Orders and the Approval Queue so the two look and behave the
|
||||
* same: a vendor-first row (icon, name, code · contact, address — the contact and address only when switched on
|
||||
* in Procurement configuration), the created DATE, status, total, branch (admin + branching), and an Actions
|
||||
* column the caller fills in. Filters: search (doc no., vendor name/code), status (unless `fixedStatus`),
|
||||
* created-date range. Sorting and paging are server-side, so a sorted page is always a true slice.
|
||||
*/
|
||||
export function PurchaseOrderListView({
|
||||
fixedStatus,
|
||||
searchPlaceholder = "Search vendor or doc no…",
|
||||
renderActions,
|
||||
actionsHeader = "Actions",
|
||||
emptyMessage,
|
||||
onTotalChange,
|
||||
}: {
|
||||
/** Pins the list to one status (the approval queue); hides the status filter. */
|
||||
fixedStatus?: PurchaseOrderStatus
|
||||
searchPlaceholder?: string
|
||||
/** Rendered in the Actions column (desktop) and the card footer (mobile). Clicks inside don't open the PO. */
|
||||
renderActions: (po: PurchaseOrderSummary, ctx: PoRowContext) => ReactNode
|
||||
actionsHeader?: string
|
||||
emptyMessage?: ReactNode
|
||||
/** Total rows matching the current filters, e.g. for a count badge in the page header. */
|
||||
onTotalChange?: (total: number | null) => void
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const showContact = procConfig.vendorContactMode !== "None"
|
||||
const showAddress = procConfig.vendorAddressEnabled
|
||||
const showBranch = isAdmin && branchingEnabled
|
||||
|
||||
const [pos, setPos] = useState<PurchaseOrderSummary[] | null>(null)
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [dateRange, setDateRange] = useState<DateRangeValue>({ preset: "All" })
|
||||
const [sortKey, setSortKey] = useState<SortKey>("created")
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("desc")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [showMoreLoading, setShowMoreLoading] = useState(false)
|
||||
|
||||
const { from, to } = dateRangeBounds(dateRange)
|
||||
// The effect depends on these primitives rather than the range object, which is a new object every render.
|
||||
const customFrom = dateRange.custom?.from?.getTime()
|
||||
const customTo = dateRange.custom?.to?.getTime()
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query, status, dateRange.preset, customFrom, customTo, sortKey, sortOrder])
|
||||
|
||||
function load() {
|
||||
setError(null)
|
||||
purchaseOrdersApi
|
||||
.list({
|
||||
page,
|
||||
pageSize,
|
||||
q: query || undefined,
|
||||
status: fixedStatus ?? (status === "All" ? undefined : status),
|
||||
from,
|
||||
to,
|
||||
sort: `${sortOrder === "desc" ? "-" : ""}${sortKey}`,
|
||||
})
|
||||
.then((res) => {
|
||||
setPos(res.items)
|
||||
setPagination(res.pagination)
|
||||
onTotalChange?.(res.pagination.totalItems)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}
|
||||
|
||||
useEffect(load, [page, pageSize, query, status, fixedStatus, from, to, sortKey, sortOrder])
|
||||
|
||||
// Admins bypass branch scoping server-side and see every PO — the header's branch switcher is the only
|
||||
// branch filter, applied client-side since /purchase-orders has no branchId query param.
|
||||
useEffect(() => {
|
||||
if (showBranch) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [showBranch])
|
||||
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
else {
|
||||
setSortKey(key)
|
||||
// Dates and totals read best newest/largest first; names and statuses A→Z.
|
||||
setSortOrder(key === "created" || key === "total" ? "desc" : "asc")
|
||||
}
|
||||
}
|
||||
|
||||
const branchName = (branchId: number) => branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
|
||||
const visible = showBranch && activeBranchId ? (pos ?? []).filter((po) => po.branchId === activeBranchId) : pos
|
||||
const hasFilters = query.length > 0 || (!fixedStatus && status !== "All") || dateRange.preset !== "All"
|
||||
const ctx: PoRowContext = { reload: load }
|
||||
const open = (poId: number) => router.push(`/dashboard/procurement/purchase-orders/${poId}`)
|
||||
|
||||
function contactOf(po: PurchaseOrderSummary) {
|
||||
return (procConfig.vendorContactMode === "Mobile" ? po.vendor?.mobile : po.vendor?.email) || ""
|
||||
}
|
||||
|
||||
/** Vendor name, then "code · contact" and (when enabled) the address — the same lines in the table and the cards. */
|
||||
function vendorBlock(po: PurchaseOrderSummary) {
|
||||
const sub = [po.vendor?.code, showContact ? contactOf(po) : ""].filter(Boolean).join(" · ")
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Truck className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium text-foreground">{po.vendor?.name ?? `Vendor #${po.vendorId}`}</p>
|
||||
{sub && <p className="truncate text-xs text-muted-foreground">{sub}</p>}
|
||||
{showAddress && po.vendor?.address && (
|
||||
<p className="max-w-64 truncate text-xs text-muted-foreground" title={po.vendor.address}>{po.vendor.address}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const totalCell = (po: PurchaseOrderSummary) => (
|
||||
<>
|
||||
<p className="font-medium text-foreground">{po.totals.currency} {formatAmount(po.totals.grandTotal)}</p>
|
||||
{procConfig.poTaxEnabled && po.totals.tax > 0 && (
|
||||
<p className="text-xs text-muted-foreground">incl. tax {formatAmount(po.totals.tax)}</p>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FilterBar
|
||||
searchFirst
|
||||
search={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
searchAriaLabel="Search purchase orders"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!fixedStatus && (
|
||||
<Select<StatusFilter>
|
||||
value={status}
|
||||
onValueChange={(v) => setStatus(v ?? "All")}
|
||||
items={STATUS_OPTIONS}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-44 text-sm" aria-label="Status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<DateRangeFilter value={dateRange} onChange={setDateRange} label="Created date" />
|
||||
{hasFilters && (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => { setSearchInput(""); setStatus("All"); setDateRange({ preset: "All" }) }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
)}
|
||||
|
||||
{!error && pos === null && (
|
||||
<div className="overflow-hidden rounded-xl border">
|
||||
<div className="flex h-11 items-center gap-4 border-b bg-muted/20 px-4">
|
||||
<Skeleton className="h-3.5 w-16" />
|
||||
<Skeleton className="ml-auto h-3.5 w-16" />
|
||||
</div>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 border-b px-4 py-4 last:border-b-0">
|
||||
<Skeleton className="size-9 shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="ml-4 h-7 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visible !== null && visible.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<ShoppingCart className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">
|
||||
{hasFilters ? "No purchase orders match your search or filters." : (emptyMessage ?? "No purchase orders yet.")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && visible !== null && visible.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{visible.map((po) => (
|
||||
<div
|
||||
key={po.poId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
open(po.poId)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !(e.target as HTMLElement).closest("[data-no-row-nav]")) open(po.poId)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
{vendorBlock(po)}
|
||||
<PoStatusBadge status={po.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Total</p>
|
||||
{totalCell(po)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Created</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(po.createdAt)}</p>
|
||||
</div>
|
||||
{showBranch && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">Branch</p>
|
||||
<Badge variant="outline" className="mt-0.5 h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
||||
{branchName(po.branchId)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
{renderActions(po, ctx)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && pos !== null && pos.length < pagination.totalItems && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => { setShowMoreLoading(true); setPageSize((s) => s + MOBILE_PAGE_SIZE_STEP) }}
|
||||
disabled={showMoreLoading}
|
||||
>
|
||||
{showMoreLoading ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table, styled like the Items table. */}
|
||||
<div className="hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Vendor" active={sortKey === "vendor"} order={sortOrder} onClick={() => toggleSort("vendor")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Created" active={sortKey === "created"} order={sortOrder} onClick={() => toggleSort("created")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Status" active={sortKey === "status"} order={sortOrder} onClick={() => toggleSort("status")} />
|
||||
</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">
|
||||
<SortableHeader label="Grand total" active={sortKey === "total"} order={sortOrder} onClick={() => toggleSort("total")} />
|
||||
</TableHead>
|
||||
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">{actionsHeader}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visible.map((po) => (
|
||||
<TableRow
|
||||
key={po.poId}
|
||||
className="cursor-pointer align-top"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
open(po.poId)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5">{vendorBlock(po)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(po.createdAt)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5"><PoStatusBadge status={po.status} /></TableCell>
|
||||
<TableCell className="px-3 py-3.5">{totalCell(po)}</TableCell>
|
||||
{showBranch && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
||||
{branchName(po.branchId)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className={cn("px-3 py-3.5")} data-no-row-nav>
|
||||
<div className="flex items-center gap-1">{renderActions(po, ctx)}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden md:block">
|
||||
<PaginationFooter
|
||||
pagination={pagination}
|
||||
onPageChange={setPage}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageSizeChange={(size) => { setPage(1); setPageSize(size) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -32,15 +32,26 @@ export function RfqStatusBadge({ status }: { status: RfqStatus }) {
|
||||
|
||||
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 === "Cancelled" || status === "Rejected") 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
|
||||
}
|
||||
|
||||
const poStatusLabel: Record<PurchaseOrderStatus, string> = {
|
||||
Draft: "Draft",
|
||||
PendingApproval: "Pending approval",
|
||||
Approved: "Approved",
|
||||
PartiallyReceived: "Partially received",
|
||||
FullyReceived: "Fully received",
|
||||
Closed: "Closed",
|
||||
Cancelled: "Cancelled",
|
||||
Rejected: "Rejected",
|
||||
}
|
||||
|
||||
export function PoStatusBadge({ status }: { status: PurchaseOrderStatus }) {
|
||||
return (
|
||||
<Badge variant="outline" className={`${badgeSize} ${poClass(status)}`}>
|
||||
{status}
|
||||
{poStatusLabel[status] ?? status}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"use client"
|
||||
|
||||
import { Fragment, useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
import { formatWarrantyPeriod, parseWarrantyInput, WarrantyInputUnit, warrantyPeriodGroups } from "@/lib/warranty"
|
||||
import { ProductConfig, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FieldError } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
/**
|
||||
* Warranty choice for an existing item: Non-Warranty / Warranty plus a period. Same options as the New Item
|
||||
* form — the default periods, the saved custom periods (when custom periods are on), and a one-off
|
||||
* "Custom period" typed here that applies to this item only and is never added to the Warranty Periods master.
|
||||
* The server validates the period again (ItemService.ValidateWarrantyPeriod).
|
||||
*/
|
||||
export function WarrantyFields({
|
||||
config,
|
||||
customMonths,
|
||||
warranty,
|
||||
months,
|
||||
onChange,
|
||||
error,
|
||||
disabled,
|
||||
}: {
|
||||
config: ProductConfig
|
||||
/** Active rows of the Warranty Periods master, in months. */
|
||||
customMonths: number[]
|
||||
warranty: Warranty
|
||||
months: WarrantyPeriodMonths | null
|
||||
onChange: (warranty: Warranty, months: WarrantyPeriodMonths | null) => void
|
||||
error?: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const groups = warrantyPeriodGroups(config, customMonths)
|
||||
// The item's own length when it is not one of the offered periods (a one-off custom period saved earlier).
|
||||
const offered = [...groups.defaults, ...groups.custom]
|
||||
const ownLength = months !== null && !offered.includes(months) ? months : null
|
||||
const selectGroups = [
|
||||
{ label: "Default periods", months: groups.defaults },
|
||||
{ label: "Custom periods", months: groups.custom },
|
||||
{ label: "This item only", months: ownLength !== null ? [ownLength] : [] },
|
||||
].filter((g) => g.months.length > 0)
|
||||
|
||||
const [customOpen, setCustomOpen] = useState(false)
|
||||
const [amount, setAmount] = useState("")
|
||||
const [unit, setUnit] = useState<WarrantyInputUnit>("months")
|
||||
const [customError, setCustomError] = useState<string | null>(null)
|
||||
|
||||
function closeCustom() {
|
||||
setCustomOpen(false)
|
||||
setAmount("")
|
||||
setUnit("months")
|
||||
setCustomError(null)
|
||||
}
|
||||
|
||||
function applyCustom() {
|
||||
const parsed = parseWarrantyInput(amount, unit, customMonths)
|
||||
if (parsed.error !== undefined) {
|
||||
setCustomError(parsed.error)
|
||||
return
|
||||
}
|
||||
onChange("Warranty", parsed.months as WarrantyPeriodMonths)
|
||||
closeCustom()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-base">Warranty</Label>
|
||||
<RadioGroup
|
||||
value={warranty}
|
||||
onValueChange={(v) => {
|
||||
const next = v as Warranty
|
||||
onChange(next, next === "NonWarranty" ? null : months)
|
||||
}}
|
||||
className="flex flex-col gap-3 sm:flex-row sm:gap-6"
|
||||
>
|
||||
<label className="flex items-center gap-2.5">
|
||||
<RadioGroupItem value="NonWarranty" disabled={disabled} />
|
||||
<span className="text-base font-medium">Non-Warranty</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2.5">
|
||||
<RadioGroupItem value="Warranty" disabled={disabled} />
|
||||
<span className="text-base font-medium">Warranty</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
|
||||
{warranty === "Warranty" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label required>Warranty period</Label>
|
||||
<Select<WarrantyPeriodMonths>
|
||||
value={months}
|
||||
onValueChange={(v) => v && onChange("Warranty", v)}
|
||||
items={selectGroups.flatMap((g) => g.months).map((m) => ({ label: formatWarrantyPeriod(m), value: m as WarrantyPeriodMonths }))}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-11! w-full text-base sm:w-60" aria-invalid={!!error}>
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectGroups.map((group, i) => (
|
||||
<Fragment key={group.label}>
|
||||
{i > 0 && <SelectSeparator />}
|
||||
<SelectGroup>
|
||||
<SelectLabel>{group.label}</SelectLabel>
|
||||
{group.months.map((m) => (
|
||||
<SelectItem key={m} value={m} className="text-base">
|
||||
{formatWarrantyPeriod(m)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[error ? { message: error } : undefined]} />
|
||||
|
||||
{config.customWarrantyPeriodsEnabled &&
|
||||
(customOpen ? (
|
||||
<div className="mt-2 flex flex-col gap-2 rounded-lg border border-dashed p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
For this item only — it isn't added to Warranty Periods or offered on other items.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
step={1}
|
||||
autoFocus
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value)
|
||||
setCustomError(null)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
applyCustom()
|
||||
} else if (e.key === "Escape") closeCustom()
|
||||
}}
|
||||
placeholder="e.g. 2"
|
||||
aria-label="Warranty length"
|
||||
aria-invalid={!!customError}
|
||||
className="h-10 w-24 text-base"
|
||||
/>
|
||||
<Select<WarrantyInputUnit>
|
||||
value={unit}
|
||||
onValueChange={(v) => v && setUnit(v)}
|
||||
items={[
|
||||
{ label: "Months", value: "months" },
|
||||
{ label: "Years", value: "years" },
|
||||
]}
|
||||
>
|
||||
<SelectTrigger className="h-10! w-28 text-base" aria-label="Unit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="months" className="text-base">Months</SelectItem>
|
||||
<SelectItem value="years" className="text-base">Years</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" size="sm" onClick={applyCustom}>Use</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={closeCustom}>Cancel</Button>
|
||||
</div>
|
||||
{customError && <p className="text-sm text-destructive">{customError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" variant="link" size="sm" className="h-auto w-fit px-0" onClick={() => setCustomOpen(true)} disabled={disabled}>
|
||||
<Plus className="size-3.5" />
|
||||
Custom period
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"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 { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import {
|
||||
A4_SHEET_CLASS,
|
||||
ACCENT,
|
||||
formatDate,
|
||||
InvoicePreviewFrame,
|
||||
money,
|
||||
PaperSize,
|
||||
printColorExact,
|
||||
qty,
|
||||
RECEIPT_SHEET_BASE_CLASS,
|
||||
RECEIPT_SHEET_CLASS,
|
||||
ReceiptRow,
|
||||
ReceiptRule,
|
||||
ReceiptWidth,
|
||||
SectionTitle,
|
||||
} from "@/components/invoice-template/InvoicePrintKit"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { Grn } from "@/types/grn"
|
||||
import { Vendor, Warehouse } from "@/types/master-data"
|
||||
|
||||
/*
|
||||
* Printable goods-received note in the same three paper sizes as the invoices — A4, 80mm and 58mm receipt — sharing
|
||||
* their preview frame, PDF export and print plumbing. A draft prints with a DRAFT marker so it can't be mistaken for a
|
||||
* posted receipt.
|
||||
*/
|
||||
|
||||
interface GrnDoc {
|
||||
grn: Grn
|
||||
vendor: Vendor | null
|
||||
warehouse: Warehouse | null
|
||||
poDocNo: string | null
|
||||
company: CompanyProfile | null
|
||||
showLogo: boolean
|
||||
/** itemId -> "SKU — name" */
|
||||
itemLabels: Map<number, { sku: string; name: string }>
|
||||
}
|
||||
|
||||
const statusMark = (grn: Grn) => (grn.status === "Draft" ? "DRAFT" : null)
|
||||
|
||||
function A4Sheet({ grn, vendor, warehouse, poDocNo, company, showLogo, itemLabels }: GrnDoc) {
|
||||
const subtotal = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
|
||||
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
|
||||
const total = grn.lines.reduce((s, l) => s + l.lineTotal, 0)
|
||||
return (
|
||||
<div className={A4_SHEET_CLASS} style={printColorExact}>
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold tracking-wide text-neutral-900">GOODS RECEIVED 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">Date</dt>
|
||||
<dd>{formatDate(grn.postedAt ?? grn.createdAt)}</dd>
|
||||
<dt className="text-xs font-bold text-neutral-900">Against</dt>
|
||||
<dd>{poDocNo ? `PO ${poDocNo}` : grn.poId ? `PO #${grn.poId}` : "Direct receipt"}</dd>
|
||||
{statusMark(grn) && (
|
||||
<>
|
||||
<dt className="text-xs font-bold text-neutral-900">Status</dt>
|
||||
<dd className="font-semibold" style={{ color: ACCENT }}>{statusMark(grn)}</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>
|
||||
|
||||
<div className="mt-5 mb-7">
|
||||
<div className="h-1 bg-neutral-900" />
|
||||
<div className="mt-0.5 h-0.5" style={{ backgroundColor: ACCENT }} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-10">
|
||||
<div>
|
||||
<SectionTitle>Received from</SectionTitle>
|
||||
<div className="leading-snug">
|
||||
<p className="font-bold text-neutral-900">{vendor?.name ?? `Vendor #${grn.vendorId}`}</p>
|
||||
{vendor?.code && <p>Vendor code: {vendor.code}</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.code} — ${warehouse.name}` : `Warehouse #${grn.warehouseId}`}</p>
|
||||
{warehouse?.address && <p>{warehouse.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<SectionTitle>Goods Received</SectionTitle>
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y-2 border-neutral-900 text-xs font-bold text-neutral-900">
|
||||
<th className="px-2 py-2.5 text-left">Item</th>
|
||||
<th className="px-2 py-2.5 text-right">Qty</th>
|
||||
<th className="px-2 py-2.5 text-right">Unit cost</th>
|
||||
<th className="px-2 py-2.5 text-right">Disc %</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((l) => {
|
||||
const item = itemLabels.get(l.itemId)
|
||||
return (
|
||||
<tr key={l.grnLineId} className="border-b border-neutral-300">
|
||||
<td className="px-2 py-2.5">
|
||||
<p className="font-medium text-neutral-900">{item?.name ?? `Item #${l.itemId}`}</p>
|
||||
{item?.sku && <p className="text-xs text-neutral-500">{item.sku}</p>}
|
||||
{l.batchNo && <p className="text-xs text-neutral-500">Batch {l.batchNo}</p>}
|
||||
</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{qty(l.qty)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{money(l.unitCost)}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{l.discountPct > 0 ? l.discountPct : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right tabular-nums">{l.vatAmount > 0 ? money(l.vatAmount) : "—"}</td>
|
||||
<td className="px-2 py-2.5 text-right font-medium tabular-nums">{money(l.lineTotal)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end print:break-inside-avoid">
|
||||
<div className="w-72 border border-neutral-900">
|
||||
<div className="space-y-1.5 px-3.5 py-3 text-xs">
|
||||
<div className="flex justify-between font-bold text-neutral-900"><span>Subtotal</span><span className="tabular-nums">{money(subtotal)}</span></div>
|
||||
{vat > 0 && <div className="flex justify-between"><span>VAT</span><span className="tabular-nums">{money(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(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 grid grid-cols-2 gap-16 text-xs text-neutral-700 print:break-inside-avoid">
|
||||
<div className="border-t border-neutral-900 pt-1.5">Received by</div>
|
||||
<div className="border-t border-neutral-900 pt-1.5">Delivered by</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 80mm / 58mm thermal layout: one centred column, no fills. */
|
||||
function ReceiptSheet({ grn, vendor, warehouse, poDocNo, company, showLogo, itemLabels, width }: GrnDoc & { width: ReceiptWidth }) {
|
||||
const subtotal = grn.lines.reduce((s, l) => s + l.receivedValue, 0)
|
||||
const vat = grn.lines.reduce((s, l) => s + l.vatAmount, 0)
|
||||
const total = grn.lines.reduce((s, l) => s + l.lineTotal, 0)
|
||||
return (
|
||||
<div className={cn(RECEIPT_SHEET_BASE_CLASS, RECEIPT_SHEET_CLASS[width])} style={printColorExact}>
|
||||
<div className="text-center">
|
||||
{showLogo && (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed
|
||||
<img src={company!.logoUrl!} alt={company!.legalName} className="mx-auto mb-1.5 h-10 w-auto max-w-[50mm] object-contain grayscale" />
|
||||
)}
|
||||
{company && <p className="text-sm font-bold uppercase">{company.tradeName || company.legalName}</p>}
|
||||
<p className="mt-2 text-sm font-extrabold tracking-[0.15em]">GOODS RECEIVED</p>
|
||||
{statusMark(grn) && <p className="font-bold">*** {statusMark(grn)} ***</p>}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="GRN No." value={grn.docNo} />
|
||||
<ReceiptRow label="Date" value={formatDate(grn.postedAt ?? grn.createdAt)} />
|
||||
<ReceiptRow label="Against" value={poDocNo ? `PO ${poDocNo}` : grn.poId ? `PO #${grn.poId}` : "Direct"} />
|
||||
<ReceiptRow label="Vendor" value={vendor?.name ?? `#${grn.vendorId}`} />
|
||||
<ReceiptRow label="Warehouse" value={warehouse?.code ?? `#${grn.warehouseId}`} />
|
||||
|
||||
<ReceiptRule />
|
||||
<div className="space-y-1.5">
|
||||
{grn.lines.map((l) => {
|
||||
const item = itemLabels.get(l.itemId)
|
||||
return (
|
||||
<div key={l.grnLineId}>
|
||||
<p className="font-medium">{item?.name ?? `Item #${l.itemId}`}</p>
|
||||
<div className="flex justify-between gap-2 tabular-nums">
|
||||
<span>{qty(l.qty)} x {money(l.unitCost)}</span>
|
||||
<span>{money(l.lineTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ReceiptRule />
|
||||
<ReceiptRow label="Subtotal" value={money(subtotal)} />
|
||||
{vat > 0 && <ReceiptRow label="VAT" value={money(vat)} />}
|
||||
<div className="my-1.5 border-y-2 border-black py-1 text-sm">
|
||||
<ReceiptRow label="TOTAL" value={money(total)} bold />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the GRN plus the optional decoration around it (vendor, warehouse, PO, item names, company). Only the GRN is
|
||||
* required — any other failure just leaves that block 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, companyRes, templateRes, items] = 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),
|
||||
companyProfileApi.get().catch(() => ({ data: null, etag: null })),
|
||||
invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })),
|
||||
Promise.all(itemIds.map((id) => itemsApi.get(id).then((r) => r.data).catch(() => null))),
|
||||
])
|
||||
if (cancelled) return
|
||||
const company = companyRes.data
|
||||
const itemLabels = new Map<number, { sku: string; name: string }>()
|
||||
for (const item of items) if (item) itemLabels.set(item.itemId, { sku: item.sku, name: item.name })
|
||||
setDoc({
|
||||
grn,
|
||||
vendor: vendorRes?.data ?? null,
|
||||
warehouse,
|
||||
poDocNo: po?.docNo ?? null,
|
||||
company,
|
||||
showLogo: templateRes.data?.logoPlacement !== "None" && !!company?.logoUrl,
|
||||
itemLabels,
|
||||
})
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [grnId])
|
||||
|
||||
return { doc, error }
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: GrnDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||
}
|
||||
|
||||
/** Popup print preview of a GRN — same frame as the invoice dialogs. Opens on A4 every time. */
|
||||
export function GrnReceiptDialog({
|
||||
grnId,
|
||||
docNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
grnId: number | null
|
||||
docNo?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||
<div className="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-lg font-semibold">Goods received note{docNo ? ` ${docNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
</div>
|
||||
{open && grnId !== null && <GrnReceiptPreview grnId={grnId} className="flex-1" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function GrnReceiptPreview({ grnId, className }: { grnId: number; className?: string }) {
|
||||
const { doc, error } = useGrnDoc(grnId)
|
||||
return (
|
||||
<InvoicePreviewFrame
|
||||
renderSheet={doc ? (size) => <Sheet size={size} doc={doc} /> : null}
|
||||
error={error}
|
||||
docNo={doc?.grn.docNo ?? `grn-${grnId}`}
|
||||
shareTitle={`GRN ${doc?.grn.docNo ?? ""}`.trim()}
|
||||
sharePath={`/dashboard/receiving/grn/${grnId}`}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarDays } from "lucide-react"
|
||||
|
||||
import { DateRangePicker } from "@/components/ui/date-picker"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
|
||||
export type DateRangePreset = "All" | "Today" | "7d" | "30d" | "Month" | "Custom"
|
||||
export type CustomDateRange = { from: Date | undefined; to?: Date | undefined }
|
||||
|
||||
export interface DateRangeValue {
|
||||
preset: DateRangePreset
|
||||
custom?: CustomDateRange
|
||||
}
|
||||
|
||||
const PRESETS: { value: DateRangePreset; label: string }[] = [
|
||||
{ value: "All", label: "All time" },
|
||||
{ value: "Today", label: "Today" },
|
||||
{ value: "7d", label: "Last 7 days" },
|
||||
{ value: "30d", label: "Last 30 days" },
|
||||
{ value: "Month", label: "This month" },
|
||||
{ value: "Custom", label: "Custom range" },
|
||||
]
|
||||
|
||||
export function toIsoDate(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Inclusive `YYYY-MM-DD` bounds for a filter value; empty (no bounds) for "All time" or a custom range with no start yet. */
|
||||
export function dateRangeBounds(value: DateRangeValue): { from?: string; to?: string } {
|
||||
const today = new Date()
|
||||
switch (value.preset) {
|
||||
case "All":
|
||||
return {}
|
||||
case "Custom":
|
||||
return value.custom?.from ? { from: toIsoDate(value.custom.from), to: toIsoDate(value.custom.to ?? value.custom.from) } : {}
|
||||
case "Month":
|
||||
return { from: toIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), to: toIsoDate(today) }
|
||||
default: {
|
||||
const days = value.preset === "Today" ? 1 : value.preset === "7d" ? 7 : 30
|
||||
const from = new Date(today)
|
||||
from.setDate(today.getDate() - (days - 1))
|
||||
return { from: toIsoDate(from), to: toIsoDate(today) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A date-range dropdown (presets, plus a from–to picker for "Custom range") for list-page filter bars. */
|
||||
export function DateRangeFilter({
|
||||
value,
|
||||
onChange,
|
||||
label = "Date range",
|
||||
}: {
|
||||
value: DateRangeValue
|
||||
onChange: (next: DateRangeValue) => void
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Select<DateRangePreset> value={value.preset} onValueChange={(v) => onChange({ ...value, preset: v ?? "All" })}>
|
||||
<SelectTrigger className="h-10! w-40 text-sm" aria-label={label}>
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRESETS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{value.preset === "Custom" && (
|
||||
<DateRangePicker
|
||||
value={value.custom}
|
||||
onChange={(custom) => onChange({ ...value, custom })}
|
||||
placeholder="Pick from – to dates"
|
||||
className="sm:w-72"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+36
-9
@@ -6,6 +6,15 @@ import { vendorsApi } from "@/lib/api/vendors"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { generateVendorCode } from "@/lib/vendor-code"
|
||||
import { Vendor } from "@/types/master-data"
|
||||
import { useProcurementConfig } from "@/hooks/use-procurement-config"
|
||||
import {
|
||||
EMPTY_VENDOR_OPTIONAL,
|
||||
VendorOptionalFields,
|
||||
VendorOptionalValues,
|
||||
validateVendorOptional,
|
||||
vendorOptionalPayload,
|
||||
vendorTermsTaxPayload,
|
||||
} from "@/components/vendors/VendorOptionalFields"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
@@ -40,6 +49,8 @@ export function EditVendorDialog({
|
||||
const [terms, setTerms] = useState("")
|
||||
const [taxReg, setTaxReg] = useState("")
|
||||
const [currency, setCurrency] = useState("")
|
||||
const { config: procConfig } = useProcurementConfig()
|
||||
const [optional, setOptional] = useState<VendorOptionalValues>(EMPTY_VENDOR_OPTIONAL)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
@@ -51,6 +62,7 @@ export function EditVendorDialog({
|
||||
setTerms(data.terms ?? "")
|
||||
setTaxReg(data.taxReg ?? "")
|
||||
setCurrency(data.currency)
|
||||
setOptional({ mobile: data.mobile ?? "", email: data.email ?? "", address: data.address ?? "" })
|
||||
}
|
||||
|
||||
function reload() {
|
||||
@@ -85,6 +97,7 @@ export function EditVendorDialog({
|
||||
if (!code.trim()) nextErrors.code = "Vendor code is required"
|
||||
if (!name.trim()) nextErrors.name = "Vendor name is required"
|
||||
if (!currency.trim()) nextErrors.currency = "Currency is required"
|
||||
Object.assign(nextErrors, validateVendorOptional(procConfig, optional))
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) {
|
||||
toast.error("Could not save vendor", Object.values(nextErrors)[0])
|
||||
@@ -96,7 +109,7 @@ export function EditVendorDialog({
|
||||
try {
|
||||
const result = await vendorsApi.update(
|
||||
vendorId,
|
||||
{ code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId },
|
||||
{ code, name, ...vendorTermsTaxPayload(procConfig, terms, taxReg), currency, ...vendorOptionalPayload(procConfig, optional), branchId: vendor.branchId },
|
||||
etag
|
||||
)
|
||||
toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`)
|
||||
@@ -145,14 +158,28 @@ export function EditVendorDialog({
|
||||
<Input id="ev-code" value={code} readOnly disabled placeholder="Enter a name to generate a code" className="text-muted-foreground" />
|
||||
<FieldError errors={[errors.code ? { message: errors.code } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ev-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="ev-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ev-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="ev-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
{procConfig.vendorPaymentTermsEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ev-terms">Payment terms (optional)</FieldLabel>
|
||||
<Input id="ev-terms" value={terms} onChange={(e) => setTerms(e.target.value)} placeholder="NET30" />
|
||||
</Field>
|
||||
)}
|
||||
{procConfig.vendorTaxRegEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="ev-taxreg">Tax registration (optional)</FieldLabel>
|
||||
<Input id="ev-taxreg" value={taxReg} onChange={(e) => setTaxReg(e.target.value)} placeholder="134567890-7000" />
|
||||
</Field>
|
||||
)}
|
||||
<VendorOptionalFields
|
||||
config={procConfig}
|
||||
values={optional}
|
||||
onChange={(patch) => {
|
||||
setOptional((prev) => ({ ...prev, ...patch }))
|
||||
clearErrors("mobile", "email")
|
||||
}}
|
||||
idPrefix="ev"
|
||||
errors={errors}
|
||||
/>
|
||||
<Field data-invalid={!!errors.currency}>
|
||||
<FieldLabel htmlFor="ev-currency" required>Currency</FieldLabel>
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { ProcurementConfig } from "@/types/procurement"
|
||||
|
||||
/** The optional vendor details; `""` means "not provided". */
|
||||
export interface VendorOptionalValues {
|
||||
mobile: string
|
||||
email: string
|
||||
address: string
|
||||
}
|
||||
|
||||
export const EMPTY_VENDOR_OPTIONAL: VendorOptionalValues = { mobile: "", email: "", address: "" }
|
||||
|
||||
// Mirrors VendorService.ValidateOptionalFields on the server.
|
||||
const MOBILE_PATTERN = /^\+?[0-9][0-9 \-()]{5,18}$/
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/** Format errors for the fields that are switched on. Blank is always fine — every field is optional. */
|
||||
export function validateVendorOptional(config: ProcurementConfig, v: VendorOptionalValues): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
if (config.vendorContactMode === "Mobile" && v.mobile.trim() && !MOBILE_PATTERN.test(v.mobile.trim()))
|
||||
errors.mobile = "Enter a valid mobile number (digits, optional leading +)"
|
||||
if (config.vendorContactMode === "Email" && v.email.trim() && !EMAIL_PATTERN.test(v.email.trim()))
|
||||
errors.email = "Enter a valid email address"
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* The request fields for the optional details. Only fields that are switched on are included, so a
|
||||
* hidden field is never sent (the server answers 422 CONFIG_DISABLED to one) and a blank becomes null.
|
||||
*/
|
||||
export function vendorOptionalPayload(config: ProcurementConfig, v: VendorOptionalValues) {
|
||||
return {
|
||||
...(config.vendorContactMode === "Mobile" ? { mobile: v.mobile.trim() || null } : {}),
|
||||
...(config.vendorContactMode === "Email" ? { email: v.email.trim() || null } : {}),
|
||||
...(config.vendorAddressEnabled ? { address: v.address.trim() || null } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** Payment terms / tax registration, included only while their switches are on (a blank becomes null). */
|
||||
export function vendorTermsTaxPayload(config: ProcurementConfig, terms: string, taxReg: string) {
|
||||
return {
|
||||
...(config.vendorPaymentTermsEnabled ? { terms: terms.trim() || null } : {}),
|
||||
...(config.vendorTaxRegEnabled ? { taxReg: taxReg.trim() || null } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile or Email (whichever Procurement configuration selects) and the company address
|
||||
* (when enabled). Renders nothing when both are off, so every vendor form can include it unconditionally.
|
||||
*/
|
||||
export function VendorOptionalFields({
|
||||
config,
|
||||
values,
|
||||
onChange,
|
||||
idPrefix,
|
||||
errors = {},
|
||||
}: {
|
||||
config: ProcurementConfig
|
||||
values: VendorOptionalValues
|
||||
onChange: (patch: Partial<VendorOptionalValues>) => void
|
||||
idPrefix: string
|
||||
errors?: Record<string, string>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{config.vendorContactMode === "Mobile" && (
|
||||
<Field data-invalid={!!errors.mobile}>
|
||||
<FieldLabel htmlFor={`${idPrefix}-mobile`}>Mobile number (optional)</FieldLabel>
|
||||
<Input
|
||||
id={`${idPrefix}-mobile`}
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
value={values.mobile}
|
||||
onChange={(e) => onChange({ mobile: e.target.value })}
|
||||
placeholder="+94 77 123 4567"
|
||||
maxLength={20}
|
||||
aria-invalid={!!errors.mobile}
|
||||
/>
|
||||
<FieldError errors={[errors.mobile ? { message: errors.mobile } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
{config.vendorContactMode === "Email" && (
|
||||
<Field data-invalid={!!errors.email}>
|
||||
<FieldLabel htmlFor={`${idPrefix}-email`}>Email (optional)</FieldLabel>
|
||||
<Input
|
||||
id={`${idPrefix}-email`}
|
||||
type="email"
|
||||
inputMode="email"
|
||||
value={values.email}
|
||||
onChange={(e) => onChange({ email: e.target.value })}
|
||||
placeholder="accounts@supplier.com"
|
||||
maxLength={200}
|
||||
aria-invalid={!!errors.email}
|
||||
/>
|
||||
<FieldError errors={[errors.email ? { message: errors.email } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
{config.vendorAddressEnabled && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={`${idPrefix}-address`}>Company address (optional)</FieldLabel>
|
||||
<Textarea
|
||||
id={`${idPrefix}-address`}
|
||||
value={values.address}
|
||||
onChange={(e) => onChange({ address: e.target.value })}
|
||||
placeholder="No. 12, Galle Road, Colombo 03"
|
||||
maxLength={500}
|
||||
rows={2}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user