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:
Dhananjaya99
2026-10-18 12:46:31 +05:30
parent c27db06880
commit e7b1f270a5
202 changed files with 99906 additions and 1894 deletions
@@ -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&apos;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>
)
}