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
@@ -25,7 +25,10 @@ import {
HelpCircle,
IdCard,
Inbox,
ClipboardCheck,
Landmark,
PiggyBank,
Tag,
LayoutGrid,
LayoutList,
LayoutTemplate,
@@ -38,7 +41,6 @@ import {
PackageCheck,
PackageSearch,
PackageX,
Percent,
PlayCircle,
PieChart,
Receipt,
@@ -50,6 +52,7 @@ import {
ShieldCheck,
ShoppingCart,
SlidersHorizontal,
Tags,
Truck,
Undo2,
Users,
@@ -59,6 +62,7 @@ import {
type LucideIcon,
} from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { cn } from "@/lib/utils"
import { useAuth } from "@/components/auth/AuthProvider"
import { useTrackingSettings } from "@/hooks/use-tracking-settings"
@@ -102,9 +106,11 @@ const navItems: {
icon: ListTree,
altCodes: ["products.brand", "products.uom"],
},
{ title: "Tax Rates", code: "products.tax-rates", href: "/dashboard/products/tax-rates", icon: Percent },
{ title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag },
],
},
// Open to everyone signed in: the inbox only lists what the user may approve and their own requests.
{ title: "Approvals", code: "approvals", href: "/dashboard/approvals", icon: ClipboardCheck },
{
title: "Procurement",
code: "procurement",
@@ -135,7 +141,8 @@ const navItems: {
{ title: "Customers", code: "sales.invoices", href: "/dashboard/customers", icon: Users },
{ title: "Bundle Templates", code: "sales.bundle-templates", href: "/dashboard/sales/bundles/templates", icon: LayoutList },
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
{ title: "Day End", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
{ title: "Approved Invoices", code: "sales.day-end", href: "/dashboard/sales/day-end", icon: Lock },
{ title: "Day-End Report", code: "sales.day-end-report", href: "/dashboard/sales/day-end-report", icon: LineChart },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
],
},
@@ -215,6 +222,9 @@ const navItems: {
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
{ title: "Received Cheques", code: "accounts.received-cheques", href: "/dashboard/accounts/received-cheques", icon: Inbox },
{ title: "Expenses", code: "accounts.expenses", href: "/dashboard/accounts/expenses", icon: ReceiptText },
{ title: "Expense Setup", code: "accounts.expense-setup", href: "/dashboard/accounts/expense-setup", icon: Tags },
{ title: "Bank Deposits", code: "accounts.bank-deposits", href: "/dashboard/accounts/bank-deposits", icon: PiggyBank },
],
},
{
@@ -226,6 +236,7 @@ const navItems: {
{ title: "Branches", code: "settings.branches", href: "/dashboard/settings/branches", icon: MapPin },
{ title: "Roles", code: "settings.roles", href: "/dashboard/settings/roles", icon: ShieldCheck },
{ title: "Users", code: "settings.users", href: "/dashboard/settings/users", icon: Users },
{ title: "Approvals", code: "settings.approvals", href: "/dashboard/settings/approvals", icon: ClipboardCheck },
{ title: "Configuration", code: "products.configuration", href: "/dashboard/settings/configuration", icon: SlidersHorizontal },
],
},
@@ -239,6 +250,7 @@ function SidebarContent({
onClose,
pathname,
isMobile,
approvalCount,
}: {
items: typeof navItems
collapsed: boolean
@@ -246,6 +258,8 @@ function SidebarContent({
onClose?: () => void
pathname: string
isMobile: boolean
/** Requests the user can approve — the badge on Approvals. */
approvalCount: number
}) {
const iconOnly = !isMobile && collapsed
@@ -367,6 +381,11 @@ function SidebarContent({
className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")}
/>
{!iconOnly && <span className="flex-1">{item.title}</span>}
{item.code === "approvals" && approvalCount > 0 && (
<span className="ml-auto rounded-full bg-primary px-1.5 text-[10px] font-semibold leading-4 text-primary-foreground tabular-nums">
{approvalCount}
</span>
)}
</Link>
)}
</div>
@@ -449,7 +468,16 @@ export function AppSidebar() {
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
// anything server-side.
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"])
// "approvals" is not a permission at all — the inbox is for everyone (see its nav entry).
const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock", "approvals"])
// Requests waiting for this user — the Approvals badge. Refreshed on navigation, which is when
// someone has most likely just approved or submitted something.
const [approvalCount, setApprovalCount] = useState(0)
useEffect(() => {
if (loading) return
approvalsApi.pending().then((p) => setApprovalCount(p.actionableCount)).catch(() => setApprovalCount(0))
}, [loading, pathname])
const visibleItems = loading
? []
: navItems
@@ -489,6 +517,7 @@ export function AppSidebar() {
onCollapse={() => setCollapsed((v) => !v)}
pathname={pathname}
isMobile={false}
approvalCount={approvalCount}
/>
</div>
@@ -525,6 +554,7 @@ export function AppSidebar() {
onClose={() => setMobileOpen(false)}
pathname={pathname}
isMobile={true}
approvalCount={approvalCount}
/>
</div>
</>
@@ -72,6 +72,9 @@ const ACCOUNTS_TITLES: Record<string, string> = {
"/dashboard/accounts/cheque-books/new": "New Cheque Book",
"/dashboard/accounts/received-cheques": "Received Cheques",
"/dashboard/accounts/received-cheques/new": "New Received Cheque",
"/dashboard/accounts/expenses": "Expenses",
"/dashboard/accounts/expenses/new": "New Expense",
"/dashboard/accounts/expense-setup": "Expense Setup",
}
const STOCK_TITLES: Record<string, string> = {
@@ -104,6 +107,7 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/sales/warranties") return "Warranties"
if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report"
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
@@ -113,6 +117,8 @@ function titleFromPath(pathname: string) {
if (ACCOUNTS_TITLES[pathname]) return ACCOUNTS_TITLES[pathname]
if (/^\/dashboard\/accounts\/cheque-books\/[^/]+$/.test(pathname)) return "Cheque Book"
if (/^\/dashboard\/accounts\/expenses\/[^/]+\/edit$/.test(pathname)) return "Edit Expense"
if (/^\/dashboard\/accounts\/expenses\/[^/]+$/.test(pathname)) return "Expense"
if (STOCK_TITLES[pathname]) return STOCK_TITLES[pathname]
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
@@ -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
}
@@ -0,0 +1,240 @@
"use client"
import { useState } from "react"
import { Check, CircleDashed, ShieldCheck, Undo2, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { ApprovalStep, PriceAdjustmentStatus } from "@/types/approvals"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Textarea } from "@/components/ui/textarea"
interface ApprovalPanelProps {
/** "stock adjustment" / "price adjustment" — used in the wording. */
what: string
requiredApprovals: number
/** The approval it waits for (1 or 2); null when it's no longer pending. */
pendingLevel: number | null
history: ApprovalStep[]
requestedByLabel: string
requestedAt: string
canApprove: boolean
whyNot: string | null
canWithdraw: boolean
/** What happens on the final approval, e.g. "Stock moves and the ledger is posted." */
finalEffect: string
outcome: "pending" | "applied" | "rejected" | "withdrawn"
outcomeAt?: string | null
rejectReason?: string | null
onApprove: (comment: string | undefined) => Promise<void>
onReject: (reason: string) => Promise<void>
onWithdraw: () => Promise<void>
}
/**
* The approval trail and the actions for the current user (docs/DUAL_APPROVAL_PLAN.md §10): who
* requested it, each level's decision, and Approve / Reject / Withdraw when the user may.
*/
export function ApprovalPanel(props: ApprovalPanelProps) {
const { what, requiredApprovals, pendingLevel, history, canApprove, whyNot, canWithdraw, outcome } = props
const [busy, setBusy] = useState<"approve" | "reject" | "withdraw" | null>(null)
const [comment, setComment] = useState("")
const [rejectOpen, setRejectOpen] = useState(false)
const [reason, setReason] = useState("")
const [reasonError, setReasonError] = useState<string | null>(null)
const isFinal = pendingLevel !== null && pendingLevel >= requiredApprovals
const levels = Array.from({ length: requiredApprovals }, (_, i) => i + 1)
async function run(kind: "approve" | "withdraw", action: () => Promise<void>) {
setBusy(kind)
try {
await action()
setComment("")
} catch {
// The caller has already shown the error.
} finally {
setBusy(null)
}
}
async function reject() {
if (!reason.trim()) {
setReasonError("Give a reason")
return
}
setBusy("reject")
try {
await props.onReject(reason.trim())
setRejectOpen(false)
setReason("")
} catch {
// The caller has already shown the error; keep the dialog open to retry.
} finally {
setBusy(null)
}
}
if (requiredApprovals === 0) {
return (
<div className="flex items-center gap-2 rounded-xl border bg-card p-4 text-sm text-muted-foreground shadow-sm">
<ShieldCheck className="size-4" />
Applied without approval (approvals were switched off when it was made).
</div>
)
}
return (
<div className="flex flex-col gap-4 rounded-xl border bg-card p-4 shadow-sm sm:p-5">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">Approval</h2>
<Badge variant="outline" className="border-transparent bg-muted text-xs">
{requiredApprovals === 2 ? "Two approvals required" : "One approval required"}
</Badge>
</div>
<ol className="flex flex-col gap-3">
<Step
state="done"
title={`Requested by ${props.requestedByLabel}`}
detail={new Date(props.requestedAt).toLocaleString()}
/>
{levels.map((level) => {
const step = history.find((s) => s.level === level)
if (step) {
return (
<Step
key={level}
state={step.decision === "Approved" ? "done" : "rejected"}
title={`${step.decision === "Approved" ? "Approved" : "Rejected"} (${ordinal(level)} approval) by ${step.decidedByName ?? `#${step.decidedBy}`}`}
detail={`${new Date(step.decidedAt).toLocaleString()}${step.comment ? ` — “${step.comment}”` : ""}`}
/>
)
}
const waiting = outcome === "pending" && pendingLevel === level
return (
<Step
key={level}
state={waiting ? "waiting" : "idle"}
title={`${ordinal(level)} approval`}
detail={waiting ? (level === 2 ? "Waiting — must be a different person from the first approver" : "Waiting for an approver") : "—"}
/>
)
})}
</ol>
{outcome === "rejected" && props.rejectReason && (
<p className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">Rejected: {props.rejectReason}</p>
)}
{outcome === "applied" && (
<p className="rounded-lg bg-success/10 p-3 text-sm text-success">
Fully approved{props.outcomeAt ? ` and applied ${new Date(props.outcomeAt).toLocaleString()}` : ""}.
</p>
)}
{outcome === "withdrawn" && <p className="rounded-lg bg-muted p-3 text-sm text-muted-foreground">Withdrawn by the requester.</p>}
{outcome === "pending" && (
<div className="flex flex-col gap-3 border-t pt-4">
{canApprove ? (
<>
<Field>
<FieldLabel htmlFor="approval-comment">Comment (optional)</FieldLabel>
<Textarea id="approval-comment" rows={2} maxLength={250} value={comment} onChange={(e) => setComment(e.target.value)} />
</Field>
<p className="text-xs text-muted-foreground">
{isFinal ? `This is the final approval. ${props.finalEffect}` : "After you approve, a second, different person must approve it too."}
</p>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button variant="outline" onClick={() => { setReasonError(null); setRejectOpen(true) }} disabled={busy !== null}>
<X className="size-4" />
Reject
</Button>
<Button onClick={() => run("approve", () => props.onApprove(comment.trim() || undefined))} disabled={busy !== null}>
<Check className="size-4" />
{busy === "approve" ? "Approving…" : isFinal ? "Approve & apply" : "Approve"}
</Button>
</div>
</>
) : (
<p className="text-sm text-muted-foreground">{whyNot ?? "You can't approve this."}</p>
)}
{canWithdraw && (
<Button variant="ghost" size="sm" className="self-end" onClick={() => run("withdraw", props.onWithdraw)} disabled={busy !== null}>
<Undo2 className="size-4" />
{busy === "withdraw" ? "Withdrawing…" : "Withdraw my request"}
</Button>
)}
</div>
)}
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Reject this {what}</DialogTitle>
<DialogDescription>Nothing will be applied. The requester is told your reason.</DialogDescription>
</DialogHeader>
<Field data-invalid={!!reasonError}>
<FieldLabel htmlFor="reject-reason" required>Reason</FieldLabel>
<Textarea id="reject-reason" rows={3} maxLength={250} value={reason} onChange={(e) => { setReason(e.target.value); setReasonError(null) }} />
<FieldError errors={[reasonError ? { message: reasonError } : undefined]} />
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectOpen(false)} disabled={busy === "reject"}>Keep it</Button>
<Button variant="destructive" onClick={reject} disabled={busy === "reject"}>{busy === "reject" ? "Rejecting…" : "Reject"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
function ordinal(level: number) {
return level === 1 ? "1st" : level === 2 ? "2nd" : `${level}th`
}
function Step({ state, title, detail }: { state: "done" | "rejected" | "waiting" | "idle"; title: string; detail: string }) {
const Icon = state === "done" ? Check : state === "rejected" ? X : CircleDashed
return (
<li className="flex items-start gap-3">
<span
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-full",
state === "done" && "bg-success/15 text-success",
state === "rejected" && "bg-destructive/15 text-destructive",
state === "waiting" && "bg-warning/15 text-warning",
state === "idle" && "bg-muted text-muted-foreground"
)}
>
<Icon className="size-3.5" />
</span>
<div className="min-w-0">
<p className={cn("text-sm font-medium", state === "idle" ? "text-muted-foreground" : "text-foreground")}>{title}</p>
<p className="text-xs text-muted-foreground">{detail}</p>
</div>
</li>
)
}
const priceLabel: Record<PriceAdjustmentStatus, string> = {
PendingApproval: "Pending",
PendingSecondApproval: "Pending 2nd",
Approved: "Applied",
Rejected: "Rejected",
Cancelled: "Withdrawn",
}
export function PriceAdjustmentStatusBadge({ status }: { status: PriceAdjustmentStatus }) {
const tone =
status === "Approved" ? "bg-success/10 text-success"
: status === "Rejected" ? "bg-destructive/10 text-destructive"
: status === "Cancelled" ? "bg-muted text-muted-foreground"
: "bg-warning/10 text-warning"
return (
<Badge variant="outline" className={cn("h-6 w-28 justify-center border-transparent px-2.5 py-1 text-sm", tone)}>
{priceLabel[status]}
</Badge>
)
}
@@ -0,0 +1,36 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ClipboardCheck } from "lucide-react"
import { approvalsApi } from "@/lib/api/approvals"
import { cn } from "@/lib/utils"
import { ApprovalDocType } from "@/types/approvals"
import { buttonVariants } from "@/components/ui/button"
/**
* "Approval queue" button for list pages: opens the Approvals inbox and shows how many requests of
* this kind are waiting for the current user (docs/DUAL_APPROVAL_PLAN.md §10).
*/
export function ApprovalQueueButton({ docType, className }: { docType?: ApprovalDocType; className?: string }) {
const [count, setCount] = useState<number | null>(null)
useEffect(() => {
approvalsApi
.pending()
.then((p) => setCount(p.items.filter((i) => i.canApprove && (!docType || i.docType === docType)).length))
.catch(() => setCount(null))
}, [docType])
return (
<Link href="/dashboard/approvals" className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full sm:w-auto", className)}>
<ClipboardCheck className="size-5" />
Approval queue
{count !== null && count > 0 && (
<span className="rounded-full bg-primary px-2 text-xs font-semibold leading-5 text-primary-foreground tabular-nums">{count}</span>
)}
</Link>
)
}
@@ -0,0 +1,76 @@
"use client"
import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js"
import { useTheme } from "next-themes"
import { Line } from "react-chartjs-2"
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
import { DayEndReportDay } from "@/types/sales"
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend)
// Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped).
const SERIES = {
light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" },
dark: { income: "#3987e5", sales: "#d95926", expenses: "#199e70", profit: "#c98500", grid: "rgba(255,255,255,0.1)", text: "#c3c2b7" },
}
/** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared
* index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */
export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) {
const { resolvedTheme } = useTheme()
const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light
const labels = days.map((d) => formatReportDate(d.date))
const line = (label: string, data: number[], color: string, width: number) => ({
label,
data,
borderColor: color,
backgroundColor: color,
borderWidth: width,
pointRadius: days.length > 45 ? 0 : 3,
pointHoverRadius: 5,
tension: 0.25,
fill: false,
})
return (
<div className="h-72 w-full">
<Line
data={{
labels,
datasets: [
line("Income", days.map((d) => d.income), c.income, 2.5),
line("Sales", days.map((d) => d.totalSales), c.sales, 2),
line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2),
line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5),
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } },
tooltip: {
callbacks: {
label: (ctx) => {
const y = ctx.parsed.y ?? 0
if (ctx.dataset.label === "Income")
return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}`
if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}`
const p = profitOrLoss(y)
return `${p.label}: ${p.amount}`
},
},
},
},
scales: {
x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } },
},
}}
/>
</div>
)
}
@@ -95,6 +95,15 @@ function SerialPicker({
const shown = (available ?? []).filter((s) => !query.trim() || s.serialNo.toLowerCase().includes(query.trim().toLowerCase()))
// "Select all" ticks the listed (search-filtered) serials, but never more than the line still needs:
// a serial line must carry exactly `units` serials.
const unselectedShown = shown.filter((s) => !selectedIds.has(s.serialId))
const toAdd = unselectedShown.slice(0, Math.max(0, units - selected.length))
function selectAll() {
onChange({ ...value, serials: [...selected, ...toAdd.map((s) => ({ serialId: s.serialId, serialNo: s.serialNo }))] })
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
@@ -127,9 +136,22 @@ function SerialPicker({
className="h-9 text-sm"
/>
{error && <p className="text-xs text-destructive">{error}</p>}
<p className={cn("text-xs", complete ? "text-success" : "text-muted-foreground")}>
{selected.length} of {units} selected
</p>
<div className="flex items-center justify-between gap-2">
<p className={cn("text-xs", complete ? "text-success" : "text-muted-foreground")}>
{selected.length} of {units} selected
</p>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
disabled={toAdd.length === 0}
onClick={selectAll}
title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined}
>
{toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"}
</Button>
</div>
<div className="max-h-60 overflow-y-auto rounded-md border">
{available === null && !error && <p className="p-3 text-xs text-muted-foreground">Loading…</p>}
{available !== null && shown.length === 0 && (
@@ -10,8 +10,9 @@ import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
/** One split of a payment: how it was paid (the GL cash/bank account) and how much. */
export type PaymentLine = { key: number; glBankAccountId: string; amount: string }
/** One split of a payment: how it was paid (the GL cash/bank account) and how much. `method` is only
* used when the editor is given `methodOptions` (sales payments: cash vs card for the Day-End Report). */
export type PaymentLine = { key: number; glBankAccountId: string; amount: string; method?: string }
export const round2 = (n: number) => Math.round(n * 100) / 100
@@ -21,6 +22,12 @@ export function localToday(): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
}
/** Sales payments' default method for an account — mirrors the backend's fallback: money into a Cash
* account is cash, into a Bank account a card payment. The user can still change it per line. */
export function salesMethodForAccount(account: CashAndBankAccountDto): string {
return account.accountType === "Cash" ? "Cash" : "Card"
}
export function paymentLinesTotal(lines: PaymentLine[]): number {
return round2(lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0))
}
@@ -46,17 +53,28 @@ interface PaymentLinesEditorProps {
/** What is owed — the fixed "Total"; Paid is the lines' sum and Remaining the difference. */
balance: number
errors: Record<string, string>
/** When given, each line also gets a payment-method select (value → label). */
methodOptions?: Record<string, string>
/** The method a line switches to when its account is picked (e.g. a Cash account ⇒ "Cash"). */
methodForAccount?: (account: CashAndBankAccountDto) => string
}
/** Payment type + paid amount rows (add/remove), with a Total / Paid amount / Remaining summary. */
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors }: PaymentLinesEditorProps) {
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) {
const paid = paymentLinesTotal(lines)
const remaining = round2(balance - paid)
const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]"
function updateLine(key: number, patch: Partial<PaymentLine>) {
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
}
function selectAccount(key: number, glBankAccountId: string) {
const account = accounts?.find((a) => String(a.accountId) === glBankAccountId)
const method = account && methodForAccount ? methodForAccount(account) : undefined
updateLine(key, method ? { glBankAccountId, method } : { glBankAccountId })
}
function addLine() {
// A new line starts with whatever is still unpaid, so a two-way split is one edit.
const key = Math.max(-1, ...lines.map((l) => l.key)) + 1
@@ -65,16 +83,17 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors
return (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-[1fr_8rem_8rem] gap-2 text-sm font-medium">
<div className={`grid ${cols} gap-2 text-sm font-medium`}>
<span>Payment type</span>
{methodOptions && <span>Method</span>}
<span>Paid amount</span>
<span />
</div>
{lines.map((line, idx) => (
<div key={line.key} className="grid grid-cols-[1fr_8rem_8rem] items-start gap-2">
<div key={line.key} className={`grid ${cols} items-start gap-2`}>
<div>
<Select<string> value={line.glBankAccountId} onValueChange={(v) => updateLine(line.key, { glBankAccountId: v ?? "" })}>
<Select<string> value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}>
<SelectTrigger
className="w-full text-base"
aria-label={`Payment type, line ${idx + 1}`}
@@ -92,6 +111,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors
</Select>
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
</div>
{methodOptions && (
<div>
<Select<string> value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}>
<SelectTrigger className="w-full text-base" aria-label={`Payment method, line ${idx + 1}`}>
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent>
{Object.entries(methodOptions).map(([value, label]) => (
<SelectItem key={value} value={value} label={label} className="text-base">
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div>
<Input
type="number"
@@ -7,7 +7,7 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { SalesInvoicePayment, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoicePayment, SalesInvoiceSummary, SalesPaymentMethod, salesPaymentMethodLabels } from "@/types/sales"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
@@ -20,6 +20,7 @@ import {
PaymentLinesEditor,
paymentLinesTotal,
round2,
salesMethodForAccount,
validatePaymentLines,
} from "@/components/sales/PaymentLinesEditor"
@@ -114,7 +115,11 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
try {
const payments = await salesApi.payInvoicesBulk({
salesInvoiceIds: ordered.map((i) => i.salesInvoiceId),
lines: lines.map((l) => ({ amount: Number(l.amount), glBankAccountId: Number(l.glBankAccountId) })),
lines: lines.map((l) => ({
amount: Number(l.amount),
glBankAccountId: Number(l.glBankAccountId),
paymentMethod: l.method as SalesPaymentMethod | undefined,
})),
reference: reference || undefined,
paymentDate,
})
@@ -135,7 +140,7 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
Pay {ordered.length} invoices — {customerName}
@@ -204,7 +209,15 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on
<Input id="bulk-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Field>
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
<PaymentLinesEditor
lines={lines}
onChange={setLines}
accounts={accounts}
balance={balance}
errors={errors}
methodOptions={salesPaymentMethodLabels}
methodForAccount={salesMethodForAccount}
/>
</FieldGroup>
<DialogFooter>
@@ -7,14 +7,21 @@ import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { CashAndBankAccountDto } from "@/types/general-ledger"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary } from "@/types/sales"
import { SalesInvoicePayment, SalesInvoiceStatus, SalesInvoiceSummary, SalesPaymentMethod, salesPaymentMethodLabels } from "@/types/sales"
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 { toast } from "@/components/ui/toast"
import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor"
import {
localToday,
PaymentLine,
PaymentLinesEditor,
round2,
salesMethodForAccount,
validatePaymentLines,
} from "@/components/sales/PaymentLinesEditor"
interface SalesInvoicePaymentDialogProps {
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
@@ -100,6 +107,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
amount: Number(line.amount),
glBankAccountId: Number(line.glBankAccountId),
paymentMethod: line.method as SalesPaymentMethod | undefined,
reference: reference || undefined,
paymentDate,
})
@@ -137,7 +145,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
</DialogHeader>
@@ -179,7 +187,15 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
<Input id="inv-pay-reference" value={reference} onChange={(e) => setReference(e.target.value)} />
</Field>
<PaymentLinesEditor lines={lines} onChange={setLines} accounts={accounts} balance={balance} errors={errors} />
<PaymentLinesEditor
lines={lines}
onChange={setLines}
accounts={accounts}
balance={balance}
errors={errors}
methodOptions={salesPaymentMethodLabels}
methodForAccount={salesMethodForAccount}
/>
</FieldGroup>
<DialogFooter>
@@ -26,10 +26,24 @@ export function CountStatusBadge({ status }: { status: CountStatus }) {
)
}
const adjustmentLabel: Record<AdjustmentStatus, string> = {
Draft: "Draft",
PendingApproval: "Pending",
PendingSecondApproval: "Pending 2nd",
Posted: "Posted",
Rejected: "Rejected",
Cancelled: "Withdrawn",
}
export function AdjustmentStatusBadge({ status }: { status: AdjustmentStatus }) {
const tone =
status === "Posted" ? success
: status === "Rejected" ? destructive
: status === "Cancelled" ? "bg-muted text-muted-foreground border-transparent"
: warning
return (
<Badge variant="outline" className={`${badgeSize} ${success}`}>
{status}
<Badge variant="outline" className={`${badgeSize} ${tone}`}>
{adjustmentLabel[status]}
</Badge>
)
}