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