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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user