feat: Implement new General Ledger frontend section with comprehensive report screens and cash/bank account management
- Added a new Ledgers sidebar section for statutory-format financial reports and cash/bank-account management. - Introduced dedicated GL client for API interactions, handling response envelopes and error management. - Developed report screens for Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, and a new Tax Report. - Implemented CSV download functionality alongside existing PDF downloads for all report screens. - Separated Cash and Bank accounts into distinct tables/endpoints, with updated create forms and unified list view. - Created a new Accounts section for Cheque Management, moving Cash/Bank Accounts from the Ledgers section. - Updated RBAC navigation to include new permissions and sub-navigation items for the added features. - Ensured compliance with GL's updated API contract, including renaming fields and adjusting response shapes. - Addressed various bugs and presentation issues, enhancing user experience across the new module.
This commit is contained in:
@@ -5,26 +5,36 @@ import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import {
|
||||
Banknote,
|
||||
BadgeDollarSign,
|
||||
BookOpen,
|
||||
BookText,
|
||||
Boxes,
|
||||
Building2,
|
||||
CalendarCheck,
|
||||
CalendarClock,
|
||||
ChevronRight,
|
||||
ClipboardList,
|
||||
CreditCard,
|
||||
Factory,
|
||||
FileBarChart,
|
||||
FileText,
|
||||
HelpCircle,
|
||||
IdCard,
|
||||
Inbox,
|
||||
Landmark,
|
||||
LayoutGrid,
|
||||
LayoutTemplate,
|
||||
LineChart,
|
||||
ListTree,
|
||||
Menu,
|
||||
Package,
|
||||
PackageCheck,
|
||||
PackageX,
|
||||
PlayCircle,
|
||||
PieChart,
|
||||
Receipt,
|
||||
Ruler,
|
||||
Scale,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShoppingCart,
|
||||
@@ -32,6 +42,7 @@ import {
|
||||
Tag,
|
||||
Truck,
|
||||
Users,
|
||||
Wallet,
|
||||
Warehouse,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -120,6 +131,34 @@ const navItems: {
|
||||
{ title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Ledgers",
|
||||
code: "ledgers",
|
||||
href: "/dashboard/ledgers",
|
||||
icon: Landmark,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale },
|
||||
{ title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark },
|
||||
{ title: "General Ledger", code: "ledgers.general-ledger", href: "/dashboard/ledgers/general-ledger", icon: BookOpen },
|
||||
{ title: "Profit & Loss", code: "ledgers.profit-and-loss", href: "/dashboard/ledgers/profit-and-loss", icon: LineChart },
|
||||
{ title: "Cash Flow", code: "ledgers.cash-flow", href: "/dashboard/ledgers/cash-flow", icon: PieChart },
|
||||
{ title: "Budget vs Actual", code: "ledgers.budget-vs-actual", href: "/dashboard/ledgers/budget-vs-actual", icon: BadgeDollarSign },
|
||||
{ title: "Tax Report", code: "ledgers.tax-report", href: "/dashboard/ledgers/tax-report", icon: Receipt },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Accounts",
|
||||
code: "accounts",
|
||||
href: "/dashboard/accounts",
|
||||
icon: CreditCard,
|
||||
chevron: true,
|
||||
children: [
|
||||
{ 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: "Settings",
|
||||
code: "settings",
|
||||
@@ -157,7 +196,13 @@ function SidebarContent({
|
||||
// route auto-expanded; user toggles are preserved across navigation.
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
useEffect(() => {
|
||||
// Adjusted during render (not in an effect) each time pathname or the
|
||||
// available item set changes — `items` starts empty while auth/nav codes
|
||||
// are loading, so this also needs to re-run once the real list arrives.
|
||||
const autoExpandKey = `${pathname}::${items.map((i) => i.code).join(",")}`
|
||||
const [lastAutoExpandKey, setLastAutoExpandKey] = useState<string | null>(null)
|
||||
if (autoExpandKey !== lastAutoExpandKey) {
|
||||
setLastAutoExpandKey(autoExpandKey)
|
||||
const parent = items.find((i) => {
|
||||
if (!i.children?.length) return false
|
||||
if (i.children.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))) return true
|
||||
@@ -168,7 +213,7 @@ function SidebarContent({
|
||||
if (parent) {
|
||||
setExpanded((prev) => (prev[parent.code] ? prev : { ...prev, [parent.code]: true }))
|
||||
}
|
||||
}, [pathname, items])
|
||||
}
|
||||
|
||||
const toggleExpand = (code: string) =>
|
||||
setExpanded((prev) => ({ ...prev, [code]: !prev[code] }))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
|
||||
@@ -41,6 +41,27 @@ const PROCUREMENT_TITLES: Record<string, string> = {
|
||||
"/dashboard/procurement/purchase-returns/new": "New Purchase Return",
|
||||
}
|
||||
|
||||
const LEDGER_TITLES: Record<string, string> = {
|
||||
"/dashboard/ledgers": "Ledgers",
|
||||
"/dashboard/ledgers/trial-balance": "Trial Balance",
|
||||
"/dashboard/ledgers/balance-sheet": "Balance Sheet",
|
||||
"/dashboard/ledgers/general-ledger": "General Ledger",
|
||||
"/dashboard/ledgers/profit-and-loss": "Profit & Loss",
|
||||
"/dashboard/ledgers/cash-flow": "Cash Flow",
|
||||
"/dashboard/ledgers/budget-vs-actual": "Budget vs Actual",
|
||||
"/dashboard/ledgers/tax-report": "Tax Report",
|
||||
}
|
||||
|
||||
const ACCOUNTS_TITLES: Record<string, string> = {
|
||||
"/dashboard/accounts": "Accounts",
|
||||
"/dashboard/accounts/bank-accounts": "Cash / Bank Accounts",
|
||||
"/dashboard/accounts/bank-accounts/new": "New Bank Account",
|
||||
"/dashboard/accounts/cheque-books": "Cheque Books",
|
||||
"/dashboard/accounts/cheque-books/new": "New Cheque Book",
|
||||
"/dashboard/accounts/received-cheques": "Received Cheques",
|
||||
"/dashboard/accounts/received-cheques/new": "New Received Cheque",
|
||||
}
|
||||
|
||||
const STOCK_TITLES: Record<string, string> = {
|
||||
"/dashboard/stock": "Stock Management",
|
||||
"/dashboard/stock/enquiry": "Stock Enquiry",
|
||||
@@ -62,6 +83,11 @@ function titleFromPath(pathname: string) {
|
||||
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
|
||||
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
|
||||
|
||||
if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname]
|
||||
|
||||
if (ACCOUNTS_TITLES[pathname]) return ACCOUNTS_TITLES[pathname]
|
||||
if (/^\/dashboard\/accounts\/cheque-books\/[^/]+$/.test(pathname)) return "Cheque Book"
|
||||
|
||||
if (STOCK_TITLES[pathname]) return STOCK_TITLES[pathname]
|
||||
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
|
||||
if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count"
|
||||
@@ -140,8 +166,7 @@ export function Header() {
|
||||
|
||||
// Read after mount, not during render: localStorage doesn't exist on the server, and
|
||||
// reading it while rendering would desync the hydration pass.
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
useEffect(() => setUser(getStoredUser()), [])
|
||||
const [user] = useState<AuthUser | null>(() => getStoredUser())
|
||||
|
||||
const markAllAsRead = () =>
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
|
||||
import { chequePagesApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { validateIssueChequeForm } from "@/lib/validations/general-ledger"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChequePage, ChequePageIssueStatus, ChequePageStatusAction, PayeeType } from "@/types/general-ledger"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
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"
|
||||
|
||||
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
|
||||
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
|
||||
[ChequePageIssueStatus.Issued]: "bg-primary/10 text-primary",
|
||||
[ChequePageIssueStatus.Cleared]: "bg-success/10 text-success",
|
||||
[ChequePageIssueStatus.Bounced]: "bg-destructive/10 text-destructive",
|
||||
[ChequePageIssueStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
[ChequePageIssueStatus.Void]: "bg-muted text-muted-foreground",
|
||||
}
|
||||
|
||||
type PendingAction = "Issue" | ChequePageStatusAction | null
|
||||
|
||||
interface ChequePageDialogProps {
|
||||
page: ChequePage | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Called with the server's response after a successful issue/status-update, so the caller's list stays in sync. */
|
||||
onUpdated: (updated: ChequePage) => void
|
||||
}
|
||||
|
||||
/** View a single cheque page's details, and (from `Unused`/`Issued`) issue it or move it through
|
||||
* Clear/Bounce/Cancel/Void — a modal rather than a separate page, so acting on several leaves from
|
||||
* a book's page list doesn't lose scroll position/context each time (docs/21 §7). */
|
||||
export function ChequePageDialog({ page, open, onOpenChange, onUpdated }: ChequePageDialogProps) {
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const [payeeType, setPayeeType] = useState<PayeeType>(PayeeType.Supplier)
|
||||
const [payeeName, setPayeeName] = useState("")
|
||||
const [payeeId, setPayeeId] = useState("")
|
||||
const [issueDate, setIssueDate] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [currencyCode, setCurrencyCode] = useState("LKR")
|
||||
const [voucherId, setVoucherId] = useState("")
|
||||
const [referenceNo, setReferenceNo] = useState("")
|
||||
const [purpose, setPurpose] = useState("")
|
||||
const [isCrossCheque, setIsCrossCheque] = useState(false)
|
||||
const [isAccountPayee, setIsAccountPayee] = useState(false)
|
||||
const [isPostDated, setIsPostDated] = useState(false)
|
||||
const [notes, setNotes] = useState("")
|
||||
const [printedBy, setPrintedBy] = useState("")
|
||||
|
||||
const [clearedDate, setClearedDate] = useState("")
|
||||
const [cancelReason, setCancelReason] = useState("")
|
||||
const [performedBy, setPerformedBy] = useState("")
|
||||
|
||||
function resetActionState() {
|
||||
setPendingAction(null)
|
||||
setErrors({})
|
||||
setPayeeType(PayeeType.Supplier)
|
||||
setPayeeName("")
|
||||
setPayeeId("")
|
||||
setIssueDate("")
|
||||
setAmount("")
|
||||
setCurrencyCode("LKR")
|
||||
setVoucherId("")
|
||||
setReferenceNo("")
|
||||
setPurpose("")
|
||||
setIsCrossCheque(false)
|
||||
setIsAccountPayee(false)
|
||||
setIsPostDated(false)
|
||||
setNotes("")
|
||||
setPrintedBy("")
|
||||
setClearedDate("")
|
||||
setCancelReason("")
|
||||
setPerformedBy("")
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) resetActionState()
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
async function submitIssue() {
|
||||
if (!page) return
|
||||
const nextErrors = validateIssueChequeForm({ payeeName, issueDate, amount })
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await chequePagesApi.issue(page.chequeNo, {
|
||||
payeeType,
|
||||
payeeId: payeeId ? Number(payeeId) : undefined,
|
||||
payeeName,
|
||||
issueDate,
|
||||
amount: Number(amount),
|
||||
currencyCode: currencyCode || undefined,
|
||||
voucherId: voucherId ? Number(voucherId) : undefined,
|
||||
referenceNo: referenceNo || undefined,
|
||||
purpose: purpose || undefined,
|
||||
isCrossCheque,
|
||||
isAccountPayee,
|
||||
isPostDated,
|
||||
notes: notes || undefined,
|
||||
printedBy: printedBy || undefined,
|
||||
})
|
||||
toast.success("Cheque issued", `${updated.chequeNo} → ${updated.payeeName}`)
|
||||
onUpdated(updated)
|
||||
resetActionState()
|
||||
} catch (err) {
|
||||
toast.error("Could not issue cheque", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitStatusAction(action: ChequePageStatusAction) {
|
||||
if (!page) return
|
||||
if (action === ChequePageStatusAction.Clear && !clearedDate) {
|
||||
setErrors({ clearedDate: "Cleared date is required" })
|
||||
return
|
||||
}
|
||||
if (action === ChequePageStatusAction.Cancel && !cancelReason.trim()) {
|
||||
setErrors({ cancelReason: "Cancel reason is required" })
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await chequePagesApi.updateStatus(page.chequeNo, {
|
||||
action,
|
||||
clearedDate: action === ChequePageStatusAction.Clear ? clearedDate : undefined,
|
||||
cancelReason: action === ChequePageStatusAction.Cancel ? cancelReason : undefined,
|
||||
performedBy: performedBy || undefined,
|
||||
})
|
||||
toast.success(`Cheque ${action.toLowerCase()}d`, updated.chequeNo)
|
||||
onUpdated(updated)
|
||||
resetActionState()
|
||||
} catch (err) {
|
||||
toast.error(`Could not ${action.toLowerCase()} cheque`, errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!page) return null
|
||||
|
||||
const availableActions: ChequePageStatusAction[] =
|
||||
page.issueStatus === ChequePageIssueStatus.Unused
|
||||
? [ChequePageStatusAction.Cancel, ChequePageStatusAction.Void]
|
||||
: page.issueStatus === ChequePageIssueStatus.Issued
|
||||
? [ChequePageStatusAction.Clear, ChequePageStatusAction.Bounce, ChequePageStatusAction.Cancel]
|
||||
: []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
Cheque {page.chequeNo}
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[page.issueStatus])}>
|
||||
{page.issueStatus}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!pendingAction && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">Payee</dt>
|
||||
<dd>{page.payeeName ?? "—"}</dd>
|
||||
<dt className="text-muted-foreground">Payee type</dt>
|
||||
<dd>{page.payeeType ?? "—"}</dd>
|
||||
<dt className="text-muted-foreground">Issue date</dt>
|
||||
<dd>{formatReportDate(page.issueDate)}</dd>
|
||||
<dt className="text-muted-foreground">Amount</dt>
|
||||
<dd>{page.amount !== null ? formatAmount(page.amount) : "—"}</dd>
|
||||
<dt className="text-muted-foreground">Reference no.</dt>
|
||||
<dd>{page.referenceNo ?? "—"}</dd>
|
||||
<dt className="text-muted-foreground">Purpose</dt>
|
||||
<dd>{page.purpose ?? "—"}</dd>
|
||||
<dt className="text-muted-foreground">Notes</dt>
|
||||
<dd>{page.notes ?? "—"}</dd>
|
||||
{page.issueStatus === ChequePageIssueStatus.Cleared && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Cleared date</dt>
|
||||
<dd>{formatReportDate(page.clearedDate)}</dd>
|
||||
</>
|
||||
)}
|
||||
{page.issueStatus === ChequePageIssueStatus.Cancelled && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Cancel reason</dt>
|
||||
<dd>{page.cancelReason ?? "—"}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{availableActions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||
{page.issueStatus === ChequePageIssueStatus.Unused && (
|
||||
<Button size="sm" onClick={() => setPendingAction("Issue")}>
|
||||
Issue Cheque
|
||||
</Button>
|
||||
)}
|
||||
{availableActions.map((action) => (
|
||||
<Button key={action} size="sm" variant="outline" onClick={() => setPendingAction(action)}>
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingAction === "Issue" && (
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.payeeName}>
|
||||
<FieldLabel htmlFor="cp-payee-name">Payee name</FieldLabel>
|
||||
<Input id="cp-payee-name" value={payeeName} onChange={(e) => setPayeeName(e.target.value)} aria-invalid={!!errors.payeeName} />
|
||||
<FieldError errors={[errors.payeeName ? { message: errors.payeeName } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-payee-type">Payee type</FieldLabel>
|
||||
<Select<PayeeType> value={payeeType} onValueChange={(v) => setPayeeType(v ?? PayeeType.Supplier)}>
|
||||
<SelectTrigger id="cp-payee-type" className="w-full text-base">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.values(PayeeType).map((t) => (
|
||||
<SelectItem key={t} value={t} label={t} className="text-base">
|
||||
{t}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-payee-id">Payee ID (optional)</FieldLabel>
|
||||
<Input id="cp-payee-id" type="number" value={payeeId} onChange={(e) => setPayeeId(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={!!errors.issueDate}>
|
||||
<FieldLabel htmlFor="cp-issue-date">Issue date</FieldLabel>
|
||||
<Input
|
||||
id="cp-issue-date"
|
||||
type="date"
|
||||
value={issueDate}
|
||||
onChange={(e) => setIssueDate(e.target.value)}
|
||||
aria-invalid={!!errors.issueDate}
|
||||
/>
|
||||
<FieldError errors={[errors.issueDate ? { message: errors.issueDate } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="cp-amount">Amount</FieldLabel>
|
||||
<Input id="cp-amount" type="number" value={amount} onChange={(e) => setAmount(e.target.value)} aria-invalid={!!errors.amount} />
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-currency">Currency</FieldLabel>
|
||||
<Input id="cp-currency" value={currencyCode} onChange={(e) => setCurrencyCode(e.target.value)} maxLength={3} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-voucher">Voucher ID (optional)</FieldLabel>
|
||||
<Input id="cp-voucher" type="number" value={voucherId} onChange={(e) => setVoucherId(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-ref">Reference no. (optional)</FieldLabel>
|
||||
<Input id="cp-ref" value={referenceNo} onChange={(e) => setReferenceNo(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-purpose">Purpose (optional)</FieldLabel>
|
||||
<Input id="cp-purpose" value={purpose} onChange={(e) => setPurpose(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={isCrossCheque} onCheckedChange={(v) => setIsCrossCheque(v === true)} />
|
||||
Cross cheque
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={isAccountPayee} onCheckedChange={(v) => setIsAccountPayee(v === true)} />
|
||||
Account payee
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={isPostDated} onCheckedChange={(v) => setIsPostDated(v === true)} />
|
||||
Post-dated
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-notes">Notes (optional)</FieldLabel>
|
||||
<Input id="cp-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-printed-by">Printed by (optional)</FieldLabel>
|
||||
<Input id="cp-printed-by" value={printedBy} onChange={(e) => setPrintedBy(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{pendingAction === ChequePageStatusAction.Clear && (
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.clearedDate}>
|
||||
<FieldLabel htmlFor="cp-cleared-date">Cleared date</FieldLabel>
|
||||
<Input
|
||||
id="cp-cleared-date"
|
||||
type="date"
|
||||
value={clearedDate}
|
||||
onChange={(e) => setClearedDate(e.target.value)}
|
||||
aria-invalid={!!errors.clearedDate}
|
||||
/>
|
||||
<FieldError errors={[errors.clearedDate ? { message: errors.clearedDate } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
|
||||
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{pendingAction === ChequePageStatusAction.Cancel && (
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.cancelReason}>
|
||||
<FieldLabel htmlFor="cp-cancel-reason">Cancel reason</FieldLabel>
|
||||
<Input
|
||||
id="cp-cancel-reason"
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.target.value)}
|
||||
aria-invalid={!!errors.cancelReason}
|
||||
/>
|
||||
<FieldError errors={[errors.cancelReason ? { message: errors.cancelReason } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
|
||||
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{(pendingAction === ChequePageStatusAction.Bounce || pendingAction === ChequePageStatusAction.Void) && (
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
|
||||
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{pendingAction && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => resetActionState()} disabled={submitting}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => (pendingAction === "Issue" ? submitIssue() : submitStatusAction(pendingAction))} disabled={submitting}>
|
||||
{submitting ? "Submitting…" : pendingAction === "Issue" ? "Issue Cheque" : pendingAction}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
import { bankAccountsApi, receivedChequesApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
CashAndBankAccountDto,
|
||||
CashBankAccountType,
|
||||
ReceivedCheque,
|
||||
ReceivedChequeStatus,
|
||||
ReceivedChequeStatusAction,
|
||||
} from "@/types/general-ledger"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
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"
|
||||
|
||||
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
|
||||
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
|
||||
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
|
||||
[ReceivedChequeStatus.Cleared]: "bg-success/10 text-success",
|
||||
[ReceivedChequeStatus.Returned]: "bg-destructive/10 text-destructive",
|
||||
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
|
||||
}
|
||||
|
||||
interface ReceivedChequeDialogProps {
|
||||
cheque: ReceivedCheque | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUpdated: (updated: ReceivedCheque) => void
|
||||
}
|
||||
|
||||
/** View a received cheque's details and (from `Received`/`Deposited`) move it through
|
||||
* Deposit/Clear/Return/Cancel — a modal, same posture as `ChequePageDialog`. */
|
||||
export function ReceivedChequeDialog({ cheque, open, onOpenChange, onUpdated }: ReceivedChequeDialogProps) {
|
||||
const [pendingAction, setPendingAction] = useState<ReceivedChequeStatusAction | null>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [depositBankAccountId, setDepositBankAccountId] = useState("")
|
||||
const [depositDate, setDepositDate] = useState("")
|
||||
const [performedBy, setPerformedBy] = useState("")
|
||||
const [notes, setNotes] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingAction !== ReceivedChequeStatusAction.Deposit || bankAccounts !== null) return
|
||||
bankAccountsApi.list(CashBankAccountType.Bank).then(setBankAccounts).catch(() => setBankAccounts([]))
|
||||
// Only fetched once, lazily, the first time Deposit is chosen.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingAction])
|
||||
|
||||
function resetActionState() {
|
||||
setPendingAction(null)
|
||||
setErrors({})
|
||||
setDepositBankAccountId("")
|
||||
setDepositDate("")
|
||||
setPerformedBy("")
|
||||
setNotes("")
|
||||
}
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) resetActionState()
|
||||
onOpenChange(next)
|
||||
}
|
||||
|
||||
async function submitStatusAction(action: ReceivedChequeStatusAction) {
|
||||
if (!cheque) return
|
||||
if (action === ReceivedChequeStatusAction.Deposit) {
|
||||
const nextErrors: Record<string, string> = {}
|
||||
if (!depositBankAccountId) nextErrors.depositBankAccountId = "Select a deposit bank account"
|
||||
if (!depositDate) nextErrors.depositDate = "Deposit date is required"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const updated = await receivedChequesApi.updateStatus(cheque.receivedChequeId, {
|
||||
action,
|
||||
depositBankAccountId: action === ReceivedChequeStatusAction.Deposit ? Number(depositBankAccountId) : undefined,
|
||||
depositDate: action === ReceivedChequeStatusAction.Deposit ? depositDate : undefined,
|
||||
notes: notes || undefined,
|
||||
performedBy: performedBy || undefined,
|
||||
})
|
||||
toast.success(`Cheque ${action.toLowerCase()}ed`, updated.chequeNo)
|
||||
onUpdated(updated)
|
||||
resetActionState()
|
||||
} catch (err) {
|
||||
toast.error(`Could not ${action.toLowerCase()} cheque`, errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!cheque) return null
|
||||
|
||||
const availableActions: ReceivedChequeStatusAction[] =
|
||||
cheque.status === ReceivedChequeStatus.Received
|
||||
? [ReceivedChequeStatusAction.Deposit, ReceivedChequeStatusAction.Cancel]
|
||||
: cheque.status === ReceivedChequeStatus.Deposited
|
||||
? [ReceivedChequeStatusAction.Clear, ReceivedChequeStatusAction.Return]
|
||||
: []
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
Cheque {cheque.chequeNo}
|
||||
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[cheque.status])}>
|
||||
{cheque.status}
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!pendingAction && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<dt className="text-muted-foreground">Received from</dt>
|
||||
<dd>{cheque.receivedFromName}</dd>
|
||||
<dt className="text-muted-foreground">Type</dt>
|
||||
<dd>{cheque.receivedFromType}</dd>
|
||||
<dt className="text-muted-foreground">Cheque date</dt>
|
||||
<dd>{formatReportDate(cheque.chequeDate)}</dd>
|
||||
<dt className="text-muted-foreground">Amount</dt>
|
||||
<dd>{formatAmount(cheque.amount)}</dd>
|
||||
<dt className="text-muted-foreground">Received date</dt>
|
||||
<dd>{formatReportDate(cheque.receivedDate)}</dd>
|
||||
<dt className="text-muted-foreground">Drawer bank</dt>
|
||||
<dd>{cheque.drawerBankName ?? "—"}</dd>
|
||||
<dt className="text-muted-foreground">Drawer branch</dt>
|
||||
<dd>{cheque.drawerBankBranch ?? "—"}</dd>
|
||||
<dt className="text-muted-foreground">Account holder</dt>
|
||||
<dd>{cheque.accountHolderName ?? "—"}</dd>
|
||||
<dt className="text-muted-foreground">Reference</dt>
|
||||
<dd>{cheque.referenceType ?? "—"}</dd>
|
||||
<dt className="text-muted-foreground">Notes</dt>
|
||||
<dd>{cheque.notes ?? "—"}</dd>
|
||||
{cheque.status === ReceivedChequeStatus.Deposited && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">Deposited to</dt>
|
||||
<dd>#{cheque.depositBankAccountId}</dd>
|
||||
<dt className="text-muted-foreground">Deposit date</dt>
|
||||
<dd>{formatReportDate(cheque.depositDate)}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{availableActions.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||
{availableActions.map((action) => (
|
||||
<Button key={action} size="sm" variant="outline" onClick={() => setPendingAction(action)}>
|
||||
{action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingAction === ReceivedChequeStatusAction.Deposit && (
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.depositBankAccountId}>
|
||||
<FieldLabel htmlFor="rc-deposit-bank">Deposit bank account</FieldLabel>
|
||||
<Select<string> value={depositBankAccountId} onValueChange={(v) => setDepositBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="rc-deposit-bank" className="w-full text-base" aria-invalid={!!errors.depositBankAccountId}>
|
||||
<SelectValue placeholder={bankAccounts === null ? "Loading…" : "Select a bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(bankAccounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.depositBankAccountId ? { message: errors.depositBankAccountId } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.depositDate}>
|
||||
<FieldLabel htmlFor="rc-deposit-date">Deposit date</FieldLabel>
|
||||
<Input
|
||||
id="rc-deposit-date"
|
||||
type="date"
|
||||
value={depositDate}
|
||||
onChange={(e) => setDepositDate(e.target.value)}
|
||||
aria-invalid={!!errors.depositDate}
|
||||
/>
|
||||
<FieldError errors={[errors.depositDate ? { message: errors.depositDate } : undefined]} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-performed-by">Performed by (optional)</FieldLabel>
|
||||
<Input id="rc-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-notes">Notes (optional)</FieldLabel>
|
||||
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{(pendingAction === ReceivedChequeStatusAction.Clear ||
|
||||
pendingAction === ReceivedChequeStatusAction.Return ||
|
||||
pendingAction === ReceivedChequeStatusAction.Cancel) && (
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-performed-by">Performed by (optional)</FieldLabel>
|
||||
<Input id="rc-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="rc-notes">Notes (optional)</FieldLabel>
|
||||
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
)}
|
||||
|
||||
{pendingAction && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => resetActionState()} disabled={submitting}>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={() => submitStatusAction(pendingAction)} disabled={submitting}>
|
||||
{submitting ? "Submitting…" : pendingAction}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { FileSpreadsheet } from "lucide-react"
|
||||
|
||||
import { reportsApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ReportType } from "@/types/general-ledger"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DownloadCsvButtonProps {
|
||||
reportType: ReportType
|
||||
/** Same filter params already sent to the Json call — outputFormat is added here, not by the caller. */
|
||||
params: Record<string, string | number | undefined>
|
||||
/** Disable while the on-screen report itself hasn't loaded (nothing to name the download after would be odd otherwise). */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/** Same mechanism as DownloadPdfButton, `outputFormat=Csv` — GL's bytes are downloaded unmodified. */
|
||||
export function DownloadCsvButton({ reportType, params, disabled }: DownloadCsvButtonProps) {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
async function handleDownload() {
|
||||
setDownloading(true)
|
||||
try {
|
||||
await reportsApi.downloadCsv(reportType, params)
|
||||
} catch (err) {
|
||||
toast.error("Could not download report", errorMessage(err))
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleDownload} disabled={disabled || downloading}>
|
||||
<FileSpreadsheet className="size-4" />
|
||||
{downloading ? "Preparing…" : "Download CSV"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Download } from "lucide-react"
|
||||
|
||||
import { reportsApi } from "@/lib/api/general-ledger"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ReportType } from "@/types/general-ledger"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
interface DownloadPdfButtonProps {
|
||||
reportType: ReportType
|
||||
/** Same filter params already sent to the Json call — outputFormat is added here, not by the caller. */
|
||||
params: Record<string, string | number | undefined>
|
||||
/** Disable while the on-screen report itself hasn't loaded (nothing to name the download after would be odd otherwise). */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function DownloadPdfButton({ reportType, params, disabled }: DownloadPdfButtonProps) {
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
|
||||
async function handleDownload() {
|
||||
setDownloading(true)
|
||||
try {
|
||||
await reportsApi.downloadPdf(reportType, params)
|
||||
} catch (err) {
|
||||
toast.error("Could not download report", errorMessage(err))
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleDownload} disabled={disabled || downloading}>
|
||||
<Download className="size-4" />
|
||||
{downloading ? "Preparing…" : "Download PDF"}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Statutory-style report header (docs/21-GENERAL-LEDGER-FRONTEND.md "Sri Lankan Standard report
|
||||
// UI"): centered title block, LKAS-aligned statement names, period/as-at line, currency note —
|
||||
// the same shape whether the report renders on screen or the downloaded PDF (the PDF itself is
|
||||
// rendered server-side by the GL service; this header is the on-screen equivalent).
|
||||
|
||||
interface ReportHeaderProps {
|
||||
title: string
|
||||
subtitle: string
|
||||
currencyNote?: string
|
||||
}
|
||||
|
||||
export function ReportHeader({
|
||||
title,
|
||||
subtitle,
|
||||
currencyNote = "All amounts in Sri Lankan Rupees (LKR) unless stated otherwise.",
|
||||
}: ReportHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1 border-b-2 border-foreground/70 px-4 pb-5 text-center">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">
|
||||
General Ledger
|
||||
</p>
|
||||
<h2 className="text-xl font-bold tracking-tight text-foreground uppercase">{title}</h2>
|
||||
<p className="text-base text-muted-foreground">{subtitle}</p>
|
||||
<p className="text-sm text-muted-foreground">{currencyNote}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { Table, TableBody, TableCell, TableFooter, TableRow } from "@/components/ui/table"
|
||||
|
||||
export interface ReportSectionLine {
|
||||
label: React.ReactNode
|
||||
amount: number
|
||||
}
|
||||
|
||||
interface ReportSectionProps {
|
||||
title: string
|
||||
lines: ReportSectionLine[]
|
||||
/** Omit to hide the total row entirely (e.g. a single-line section that would just repeat itself). */
|
||||
total?: number
|
||||
}
|
||||
|
||||
/** One bordered statement section: its lines, then an optional bold total row. Renders nothing when there are no lines. */
|
||||
export function ReportSection({ title, lines, total }: ReportSectionProps) {
|
||||
if (lines.length === 0) return null
|
||||
return (
|
||||
<div className="mb-4 rounded-lg border p-4">
|
||||
<p className="mb-2 text-sm font-semibold tracking-wide text-muted-foreground uppercase">{title}</p>
|
||||
<Table className="text-base">
|
||||
<TableBody>
|
||||
{lines.map((line, i) => (
|
||||
<TableRow key={i} className="border-0 hover:bg-transparent">
|
||||
<TableCell className="px-0 py-1.5">{line.label}</TableCell>
|
||||
<TableCell className="px-0 py-1.5 text-right tabular-nums">{formatAmount(line.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
{total !== undefined && (
|
||||
<TableFooter className="border-t bg-transparent">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell className="px-0 py-2 font-semibold">Total {title}</TableCell>
|
||||
<TableCell className="px-0 py-2 text-right font-semibold tabular-nums">{formatAmount(total)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ReportSubtotalProps {
|
||||
label: string
|
||||
amount: number
|
||||
large?: boolean
|
||||
}
|
||||
|
||||
/** A bold, unbordered subtotal/total line (Gross Profit, Net Cash From Operations, the final total, etc.). */
|
||||
export function ReportSubtotal({ label, amount, large }: ReportSubtotalProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mb-4 flex items-center justify-between rounded-lg bg-muted/40 px-4 py-3 font-bold",
|
||||
large && "text-lg"
|
||||
)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="tabular-nums">{formatAmount(amount)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user