price adjustment and stock adjustments
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user