price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
@@ -0,0 +1,438 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { Plus } from "lucide-react"
import { expenseSetupApi, expensesApi } from "@/lib/api/expenses"
import { branchesHrmApi } from "@/lib/api/hrm-masters"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Branch } from "@/types/hrm"
import { Vendor } from "@/types/master-data"
import { CreateExpenseRequest, Expense, ExpenseCategory, ExpenseTitle } from "@/types/expenses"
import { useAuth } from "@/components/auth/AuthProvider"
import { localToday } from "@/components/sales/PaymentLinesEditor"
import { Button, buttonVariants } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, 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 { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/toast"
/** What to do once the draft is saved: just keep it, post it, or open the Post & Pay dialog. */
export type ExpenseSaveAction = "draft" | "post" | "pay"
interface ExpenseFormProps {
/** The draft being edited, with its ETag; omitted for a new expense. */
existing?: { expense: Expense; etag: string | null }
/** Called with the saved (and, for "post", posted) expense. */
onSaved: (expense: Expense, action: ExpenseSaveAction) => void
cancelHref: string
}
type Errors = Partial<Record<"expenseDate" | "dueDate" | "categoryId" | "titleId" | "amount" | "periodMonth", string>>
/** Create/edit form for a Draft expense: Category → Title (filtered), payee/vendor, amount, dates. */
export function ExpenseForm({ existing, onSaved, cancelHref }: ExpenseFormProps) {
const { isAdmin, loading: authLoading, navCodes, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
const initial = existing?.expense
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
const [vendors, setVendors] = useState<Vendor[]>([])
const [branches, setBranches] = useState<Branch[]>([])
const [loadError, setLoadError] = useState<string | null>(null)
const [branchPickId, setBranchPickId] = useState<number | null>(initial?.branchId ?? null)
const [expenseDate, setExpenseDate] = useState(initial?.expenseDate ?? localToday())
const [dueDate, setDueDate] = useState(initial?.dueDate ?? "")
const [categoryId, setCategoryId] = useState<number | null>(initial?.expenseCategoryId ?? null)
const [titleId, setTitleId] = useState<number | null>(initial?.expenseTitleId ?? null)
const [vendorId, setVendorId] = useState<number | null>(initial?.vendorId ?? null)
const [payee, setPayee] = useState(initial?.payee ?? "")
const [billReference, setBillReference] = useState(initial?.billReference ?? "")
const [periodMonth, setPeriodMonth] = useState(initial?.periodMonth ?? "")
const [amount, setAmount] = useState(initial ? initial.amount.toFixed(2) : "")
const [description, setDescription] = useState(initial?.description ?? "")
const [errors, setErrors] = useState<Errors>({})
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState<ExpenseSaveAction | null>(null)
const [addTitleOpen, setAddTitleOpen] = useState(false)
const canAddTitle = isAdmin || navCodes.includes("accounts.expense-setup")
useEffect(() => {
expenseSetupApi
.list()
.then(setCategories)
.catch((err) => setLoadError(errorMessage(err)))
vendorsApi.list({ pageSize: 200 }).then((r) => setVendors(r.items)).catch(() => setVendors([]))
}, [])
useEffect(() => {
if (branchingEnabled && isAdmin) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
}, [branchingEnabled, isAdmin])
const category = categories?.find((c) => c.expenseCategoryId === categoryId) ?? null
const titles: ExpenseTitle[] = useMemo(() => category?.titles ?? [], [category])
// A draft whose category/title has since been deactivated still shows it, so the user can see what to change.
const inactiveSelection = !!initial && categories !== null && categoryId !== null && !titles.some((t) => t.expenseTitleId === titleId)
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchPickId) : myBranchId) : null
function validate(): Errors {
const next: Errors = {}
const value = Number(amount)
if (!expenseDate) next.expenseDate = "Select the expense date"
else if (expenseDate > localToday()) next.expenseDate = "Expense date cannot be in the future"
if (dueDate && expenseDate && dueDate < expenseDate) next.dueDate = "Due date cannot be before the expense date"
if (!categoryId) next.categoryId = "Select a category"
if (!titleId) next.titleId = "Select a title"
else if (inactiveSelection) next.titleId = "This title is inactive — pick another"
if (!amount || Number.isNaN(value) || value <= 0) next.amount = "Enter an amount greater than zero"
if (periodMonth && !/^\d{4}-(0[1-9]|1[0-2])$/.test(periodMonth)) next.periodMonth = "Use the month picker"
return next
}
async function save(action: ExpenseSaveAction) {
setSubmitError(null)
const nextErrors = validate()
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
const request: CreateExpenseRequest = {
branchId: effectiveBranchId ?? 0,
expenseDate,
dueDate: dueDate || null,
expenseCategoryId: categoryId!,
expenseTitleId: titleId!,
vendorId,
payee: payee.trim() || null,
billReference: billReference.trim() || null,
description: description.trim() || null,
periodMonth: periodMonth || null,
amount: Number(amount),
}
setSaving(action)
let saved: Expense
try {
saved = existing
? (await expensesApi.update(existing.expense.expenseId, request, existing.etag ?? "")).data
: (await expensesApi.create(request)).data
} catch (err) {
setSubmitError(errorMessage(err))
toast.error("Could not save expense", errorMessage(err))
setSaving(null)
return
}
if (action === "post") {
try {
saved = await expensesApi.post(saved.expenseId)
toast.success("Expense posted", `${saved.docNo} — journal ${saved.glJournalNo ?? "—"}`)
} catch (err) {
// The draft is saved either way; say so, then carry on to it.
toast.error(`${saved.docNo} was saved as a draft, but could not be posted`, errorMessage(err))
}
} else if (action === "draft") {
toast.success("Expense saved", `${saved.docNo} is a draft — post it when ready.`)
}
setSaving(null)
onSaved(saved, action)
}
if (loadError) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
if (categories === null || authLoading) return <Skeleton className="h-96 w-full" />
return (
<div className="flex flex-col gap-6">
<div className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
<FieldGroup>
{branchingEnabled && isAdmin && !existing && (
<Field className="sm:max-w-xs">
<FieldLabel>Branch</FieldLabel>
{showBranchPicker ? (
<Select<number | null> value={branchPickId} onValueChange={(v) => setBranchPickId(v)}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="Default branch" />
</SelectTrigger>
<SelectContent>
{branches.map((b) => (
<SelectItem key={b.branchId} value={b.branchId} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="flex h-11 items-center rounded-lg border border-input bg-muted px-3 text-base text-muted-foreground">
{activeBranchName ?? `Branch #${lockedBranchId}`}
</div>
)}
</Field>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.categoryId}>
<FieldLabel htmlFor="exp-category" required>Category</FieldLabel>
<Select<number | null>
value={categoryId}
onValueChange={(v) => {
setCategoryId(v)
setTitleId(null)
}}
>
<SelectTrigger id="exp-category" className="h-11! w-full text-base" aria-invalid={!!errors.categoryId}>
<SelectValue placeholder={inactiveSelection ? initial?.categoryName : "Select category"} />
</SelectTrigger>
<SelectContent>
{categories.map((c) => (
<SelectItem key={c.expenseCategoryId} value={c.expenseCategoryId} className="text-base">
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.categoryId ? { message: errors.categoryId } : undefined]} />
</Field>
<Field data-invalid={!!errors.titleId}>
<div className="flex items-center justify-between gap-2">
<FieldLabel htmlFor="exp-title" required>Title</FieldLabel>
{canAddTitle && category && (
<button type="button" className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline" onClick={() => setAddTitleOpen(true)}>
<Plus className="size-3.5" />
Add title
</button>
)}
</div>
<Select<number | null> value={titleId} onValueChange={(v) => setTitleId(v)} disabled={!category}>
<SelectTrigger id="exp-title" className="h-11! w-full text-base" aria-invalid={!!errors.titleId}>
<SelectValue placeholder={inactiveSelection ? initial?.titleName : category ? "Select title" : "Pick a category first"} />
</SelectTrigger>
<SelectContent>
{titles.map((t) => (
<SelectItem key={t.expenseTitleId} value={t.expenseTitleId} className="text-base">
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.titleId ? { message: errors.titleId } : undefined]} />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="exp-amount" required>Amount</FieldLabel>
<Input
id="exp-amount"
type="number"
min="0"
step="0.01"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-invalid={!!errors.amount}
className="h-11 text-base tabular-nums"
/>
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
<Field data-invalid={!!errors.expenseDate}>
<FieldLabel htmlFor="exp-date" required>Bill date</FieldLabel>
<Input
id="exp-date"
type="date"
max={localToday()}
value={expenseDate}
onChange={(e) => setExpenseDate(e.target.value)}
aria-invalid={!!errors.expenseDate}
className="h-11 text-base"
/>
<FieldError errors={[errors.expenseDate ? { message: errors.expenseDate } : undefined]} />
</Field>
<Field data-invalid={!!errors.dueDate}>
<FieldLabel htmlFor="exp-due">Due date</FieldLabel>
<Input
id="exp-due"
type="date"
min={expenseDate || undefined}
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
aria-invalid={!!errors.dueDate}
className="h-11 text-base"
/>
<FieldError errors={[errors.dueDate ? { message: errors.dueDate } : undefined]} />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field>
<FieldLabel htmlFor="exp-vendor">Vendor (optional)</FieldLabel>
<Select<number | null> value={vendorId} onValueChange={(v) => setVendorId(v)}>
<SelectTrigger id="exp-vendor" className="h-11! w-full text-base">
<SelectValue placeholder="No vendor" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null} className="text-base">No vendor</SelectItem>
{vendors.map((v) => (
<SelectItem key={v.vendorId} value={v.vendorId} className="text-base">
{v.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="exp-payee">Payee</FieldLabel>
<Input
id="exp-payee"
value={payee}
maxLength={150}
placeholder={vendorId ? "Defaults to the vendor" : "e.g. CEB, landlord"}
onChange={(e) => setPayee(e.target.value)}
className="h-11 text-base"
/>
</Field>
<Field>
<FieldLabel htmlFor="exp-ref">Bill / account ref</FieldLabel>
<Input id="exp-ref" value={billReference} maxLength={50} onChange={(e) => setBillReference(e.target.value)} className="h-11 text-base" />
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Field data-invalid={!!errors.periodMonth}>
<FieldLabel htmlFor="exp-period">Period covered</FieldLabel>
<Input
id="exp-period"
type="month"
value={periodMonth}
onChange={(e) => setPeriodMonth(e.target.value)}
aria-invalid={!!errors.periodMonth}
className="h-11 text-base"
/>
<FieldError errors={[errors.periodMonth ? { message: errors.periodMonth } : undefined]} />
</Field>
<Field className="sm:col-span-2">
<FieldLabel htmlFor="exp-desc">Description</FieldLabel>
<Textarea id="exp-desc" value={description} maxLength={500} rows={2} onChange={(e) => setDescription(e.target.value)} className="text-base" />
</Field>
</div>
</FieldGroup>
</div>
{submitError && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-base text-destructive">{submitError}</div>}
<div className="flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
<Link href={cancelHref} className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" variant="outline" onClick={() => save("draft")} disabled={saving !== null}>
{saving === "draft" ? "Saving…" : "Save draft"}
</Button>
<Button size="lg" variant="outline" onClick={() => save("post")} disabled={saving !== null}>
{saving === "post" ? "Posting…" : "Save & post"}
</Button>
<Button size="lg" onClick={() => save("pay")} disabled={saving !== null}>
{saving === "pay" ? "Saving…" : "Save & pay"}
</Button>
</div>
{category && (
<AddTitleDialog
open={addTitleOpen}
onOpenChange={setAddTitleOpen}
category={category}
onCreated={(title) => {
setCategories((prev) =>
prev?.map((c) => (c.expenseCategoryId === title.expenseCategoryId ? { ...c, titles: [...c.titles, title] } : c)) ?? prev
)
setTitleId(title.expenseTitleId)
}}
/>
)}
</div>
)
}
/** Quick-add a custom title under the chosen category, defaulting its GL code to the category's first title's. */
export function AddTitleDialog({
open,
onOpenChange,
category,
onCreated,
}: {
open: boolean
onOpenChange: (open: boolean) => void
category: ExpenseCategory
onCreated: (title: ExpenseTitle) => void
}) {
const [name, setName] = useState("")
const [glCode, setGlCode] = useState("")
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const openKey = open ? category.expenseCategoryId : null
const [resetFor, setResetFor] = useState<number | null>(null)
if (openKey !== null && resetFor !== openKey) {
setResetFor(openKey)
setName("")
setGlCode(category.titles[0]?.glExpenseAccountCode ?? "")
setError(null)
}
if (!open && resetFor !== null) setResetFor(null)
async function submit() {
if (!name.trim()) return setError("Enter a title name")
if (!glCode.trim()) return setError("Enter the GL expense account code")
setSaving(true)
try {
const title = await expenseSetupApi.createTitle(category.expenseCategoryId, { name: name.trim(), glExpenseAccountCode: glCode.trim() })
toast.success("Title added", `${title.name} under ${category.name}`)
onCreated(title)
onOpenChange(false)
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add title to {category.name}</DialogTitle>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="new-title-name" required>Title</FieldLabel>
<Input id="new-title-name" value={name} maxLength={100} autoFocus onChange={(e) => setName(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="new-title-gl" required>GL expense account code</FieldLabel>
<Input id="new-title-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
</Field>
{error && <p className="text-sm text-destructive">{error}</p>}
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={submit} disabled={saving}>
{saving ? "Adding…" : "Add title"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,234 @@
"use client"
import { useEffect, useState } from "react"
import { bankAccountsApi } from "@/lib/api/general-ledger"
import { expensesApi } from "@/lib/api/expenses"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { Expense, ExpensePaymentMethod, ExpenseSummary, expensePaymentMethodLabels } from "@/types/expenses"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
import {
localToday,
PaymentLine,
PaymentLinesEditor,
round2,
validatePaymentLines,
} from "@/components/sales/PaymentLinesEditor"
interface ExpensePaymentDialogProps {
// ExpenseSummary is enough (id/docNo/status/amount/balance), so a list row can open it directly.
expense: ExpenseSummary | null
open: boolean
onOpenChange: (open: boolean) => void
/** Called after a successful submit; the caller reloads whatever it shows. */
onDone: (updated: Expense | null) => void
}
const methods = Object.keys(expensePaymentMethodLabels) as ExpensePaymentMethod[]
/** Pay an expense, in full or partially — split across one or more GL cash/bank accounts, posted as
* one GL entry. A Draft expense is posted and paid in a single server transaction (Post & Pay), so a
* failure never leaves it half-done; any unpaid remainder stays as the balance for a later payment. */
export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: ExpensePaymentDialogProps) {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
// The list comes from the external GL service, which can be slow or fail; keep the error so the
// user sees why the list is empty and can retry, instead of a silently blank dropdown.
const [accountsError, setAccountsError] = useState<string | null>(null)
const [lines, setLines] = useState<PaymentLine[]>([])
const [method, setMethod] = useState<ExpensePaymentMethod>("Cash")
const [chequeNo, setChequeNo] = useState("")
const [reference, setReference] = useState("")
const [paymentDate, setPaymentDate] = useState(localToday)
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Reset for a fresh open (or a different expense) during render — the same "adjust state while
// rendering" pattern as PurchaseInvoicePaymentDialog.
const openKey = open && expense ? `${expense.expenseId}` : null
const [resetFor, setResetFor] = useState<string | null>(null)
if (openKey !== null && resetFor !== openKey) {
setResetFor(openKey)
setLines([{ key: 0, glBankAccountId: "", amount: expense!.balanceAmount.toFixed(2) }])
setMethod("Cash")
setChequeNo("")
setReference("")
setPaymentDate(localToday())
setErrors({})
// A failed load is retried on the next open rather than cached as an empty list.
if (accountsError) {
setAccounts(null)
setAccountsError(null)
}
}
if (!open && resetFor !== null) setResetFor(null)
useEffect(() => {
if (!open || accounts !== null) return
bankAccountsApi
.list("Both")
.then((rows) => {
setAccounts(rows)
setAccountsError(null)
})
.catch((err) => {
setAccounts([])
setAccountsError(errorMessage(err))
})
}, [open, accounts])
function retryAccounts() {
setAccounts(null)
setAccountsError(null)
}
if (!expense) return null
const isDraft = expense.status === "Draft"
const balance = expense.balanceAmount
async function submit() {
if (!expense) return
const nextErrors = validatePaymentLines(lines, balance)
if (!paymentDate) nextErrors.paymentDate = "Select the payment date"
else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future"
if (method === "Cheque" && !chequeNo.trim()) nextErrors.chequeNo = "Enter the cheque number"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
const request = {
lines: lines.map((l) => ({ glBankAccountId: Number(l.glBankAccountId), amount: Number(l.amount) })),
paymentMethod: method,
chequeNo: method === "Cheque" ? chequeNo.trim() : undefined,
reference: reference.trim() || undefined,
paymentDate,
}
setSubmitting(true)
try {
if (isDraft) {
const updated = await expensesApi.postAndPay(expense.expenseId, request)
toast.success(
"Expense posted and paid",
`${updated.docNo} — ${formatAmount(updated.paidAmount)} paid${updated.balanceAmount > 0 ? `, ${formatAmount(updated.balanceAmount)} still due` : " in full"}.`
)
onDone(updated)
} else {
const rows = await expensesApi.pay(expense.expenseId, request)
const paid = round2(rows.reduce((sum, r) => sum + r.amount, 0))
toast.success(
"Payment recorded",
`${formatAmount(paid)} posted to the ledger (${rows[0]?.glJournalNo ?? "—"}) — ${paid >= balance ? "fully paid" : "partially paid"}.`
)
onDone(null)
}
onOpenChange(false)
} catch (err) {
toast.error(isDraft ? "Could not post and pay the expense" : "Could not record payment", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{isDraft ? "Post & pay" : "Pay"} expense {expense.docNo}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
{isDraft && "This expense is still a draft — it will be posted and paid in one step. "}
Pay the full balance or change the amount to pay part of it now.{" "}
{expense.categoryName} · {expense.titleName} — balance due:{" "}
<span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
</p>
<FieldGroup>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field data-invalid={!!errors.paymentDate}>
<FieldLabel htmlFor="exp-pay-date" required>Payment date</FieldLabel>
<Input
id="exp-pay-date"
type="date"
max={localToday()}
value={paymentDate}
onChange={(e) => setPaymentDate(e.target.value)}
aria-invalid={!!errors.paymentDate}
/>
<FieldError errors={[errors.paymentDate ? { message: errors.paymentDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="exp-pay-method" required>Payment method</FieldLabel>
<Select<ExpensePaymentMethod> value={method} onValueChange={(v) => v && setMethod(v)}>
<SelectTrigger id="exp-pay-method" className="w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{methods.map((m) => (
<SelectItem key={m} value={m} className="text-base">
{expensePaymentMethodLabels[m]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{method === "Cheque" && (
<Field data-invalid={!!errors.chequeNo}>
<FieldLabel htmlFor="exp-pay-cheque" required>Cheque no.</FieldLabel>
<Input
id="exp-pay-cheque"
value={chequeNo}
maxLength={30}
onChange={(e) => setChequeNo(e.target.value)}
aria-invalid={!!errors.chequeNo}
/>
<FieldError errors={[errors.chequeNo ? { message: errors.chequeNo } : undefined]} />
</Field>
)}
<Field>
<FieldLabel htmlFor="exp-pay-reference">Reference (optional)</FieldLabel>
<Input id="exp-pay-reference" value={reference} maxLength={100} onChange={(e) => setReference(e.target.value)} />
</Field>
</div>
{accountsError && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
<span>Could not load cash/bank accounts from the ledger: {accountsError}</span>
<Button type="button" size="sm" variant="outline" onClick={retryAccounts}>
Retry
</Button>
</div>
)}
{accounts !== null && accounts.length === 0 && !accountsError && (
<p className="text-sm text-muted-foreground">
No cash/bank accounts exist in the ledger yet — add one under Accounts → Cash / Bank Accounts.
</p>
)}
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
</FieldGroup>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={submit} disabled={submitting}>
{submitting ? "Recording…" : isDraft ? "Post & record payment" : "Record payment"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,14 @@
import { Badge } from "@/components/ui/badge"
import { BankDepositStatus } from "@/types/bank-deposits"
// Same fixed-width pill as expense-badges.tsx.
const badgeSize = "h-6 w-28 justify-center px-2.5 py-1 text-sm"
export function BankDepositStatusBadge({ status }: { status: BankDepositStatus }) {
const tone = status === "Posted" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
return (
<Badge variant="outline" className={`${badgeSize} border-transparent ${tone}`}>
{status}
</Badge>
)
}
@@ -0,0 +1,44 @@
import { Badge } from "@/components/ui/badge"
import { ExpensePaymentStatus, ExpenseStatus } from "@/types/expenses"
// Fixed width so every pill lines up — same convention as components/procurement/status-badges.tsx.
const badgeSize = "h-6 w-32 justify-center px-2.5 py-1 text-sm"
function statusClass(status: ExpenseStatus) {
if (status === "Draft") return "bg-warning/10 text-warning border-transparent"
if (status === "Posted") return "bg-success/10 text-success border-transparent"
return "bg-muted text-muted-foreground border-transparent" // Cancelled
}
export function ExpenseStatusBadge({ status }: { status: ExpenseStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${statusClass(status)}`}>
{status}
</Badge>
)
}
function paymentClass(status: ExpensePaymentStatus) {
if (status === "Paid") return "bg-success/10 text-success border-transparent"
if (status === "PartiallyPaid") return "bg-warning/10 text-warning border-transparent"
return "bg-destructive/10 text-destructive border-transparent" // Unpaid
}
const paymentLabel: Record<ExpensePaymentStatus, string> = {
Unpaid: "Unpaid",
PartiallyPaid: "Partially paid",
Paid: "Paid",
}
export function ExpensePaymentStatusBadge({ status }: { status: ExpensePaymentStatus }) {
return (
<Badge variant="outline" className={`${badgeSize} ${paymentClass(status)}`}>
{paymentLabel[status]}
</Badge>
)
}
/** Posted with a balance and a due date already passed (compared as local `YYYY-MM-DD`). */
export function isExpenseOverdue(e: { status: ExpenseStatus; balanceAmount: number; dueDate: string | null }, today: string): boolean {
return e.status === "Posted" && e.balanceAmount > 0 && e.dueDate !== null && e.dueDate < today
}