price adjustment and stock adjustments
This commit is contained in:
@@ -4,6 +4,68 @@ Legend: `[ ]` not started · `[~]` in progress · `[x]` done
|
||||
Spec: `docs/20-FRONTEND.md` (flows + rules) · `docs/11-BACKEND-PHASE1.md` (API contract)
|
||||
Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit** as the code. When ticking `[x]`, append a short note + any deviation.
|
||||
|
||||
## Dual approval — stock & price adjustments (2026-10-12) — `docs/DUAL_APPROVAL_PLAN.md`
|
||||
|
||||
- [x] **Approvals inbox** (`app/dashboard/approvals`, sidebar entry for everyone with a badge of actionable requests):
|
||||
"Waiting for you" (Approve / Approve & apply / Reject with reason) and "Waiting for others" (your own requests, with why).
|
||||
- [x] **Stock → Adjustments**: status filter, rows open the new detail page `adjustments/[id]` (lines, requested serials,
|
||||
approval timeline, Approve / Reject / Withdraw via `components/approvals/ApprovalPanel.tsx`). New adjustment, Wastage and
|
||||
Count post now say "submitted for approval" with a link when the result is pending. Wastage list hides rejected/withdrawn
|
||||
requests and tags pending ones. `AdjustmentStatusBadge` covers the new statuses.
|
||||
- [x] **Products → Price Adjustments** (list, `new` — add items, edit new min/max/flat from the current prices, reason —
|
||||
and `[id]` with old → new, a stale-request warning and the approval panel). Item page: selling prices read-only in Edit
|
||||
while price approval is on, "Request price change" links, and a Price history panel.
|
||||
- [x] **Settings → Approvals** (`app/dashboard/settings/approvals`): levels per document, thresholds, level-2 roles.
|
||||
- [x] Types `types/approvals.ts`, API `lib/api/approvals.ts`; `lib/api/stock-adjustments.ts` gains approve/reject/cancel.
|
||||
`tsc` clean; no new lint errors (the setState-in-effect errors in touched pages were already there).
|
||||
|
||||
## Bank Deposit module (2026-10-08) — `docs/BANK_DEPOSIT_MODULE_PLAN.md`
|
||||
|
||||
- [x] **Accounts → Bank Deposits** (`app/dashboard/accounts/bank-deposits`): list (search, status, business-date range,
|
||||
bank-account filter, server total in the footer, CSV export) with a "Cash in hand by day" view (opening, cash in,
|
||||
cash out, deposited, closing; the filter's range capped at 92 days, default last 30). Cards: deposited (filtered),
|
||||
cash in hand now, net cash today. Admins' header branch switcher is sent as `branchId` (totals are server-side).
|
||||
- [x] `new/`: branch (admins), business date, deposit date, from cash account (preselects `defaultCashAccountCode`),
|
||||
to bank account, amount (defaults to "available to deposit", blocked above it), slip no., depositor, remarks; a cash
|
||||
position panel beside it. `[id]/`: details + cancel with reason (reverses GL). `lib/api/bank-deposits.ts`,
|
||||
`types/bank-deposits.ts`, `components/accounts/bank-deposit-badges.tsx`. Sidebar + Accounts hub card added.
|
||||
- [x] **Day-End Report**: the one-line "Net cash in hand" became a Cash in hand group (opening, net cash today,
|
||||
deposited to bank, remaining — amber while > 0) with the day's deposits table; the daily table adds Deposited and
|
||||
Cash in hand columns. PDFs: single day adds the cash block + deposits; day-wise adds a separate "Cash in hand and bank
|
||||
deposits" table. **Deviation:** not two more columns on the day-wise PDF table — A4 portrait is already full at 9 columns.
|
||||
|
||||
## Day-End Sales Report (2026-10-08) — `docs/DAY_END_SALES_REPORT_PLAN.md`
|
||||
|
||||
- [x] Sales payment method: `PaymentLinesEditor` takes optional `methodOptions`/`methodForAccount` and shows a Method column
|
||||
(picking a Cash account sets Cash, a Bank account sets Card; changeable). Used by `SalesInvoicePaymentDialog` and
|
||||
`SalesInvoiceBulkPaymentDialog` only — the expense/purchase dialogs are unchanged. Types in `types/sales.ts`, `types/bundles.ts`.
|
||||
- [x] **Sales → Day-End Report** (`app/dashboard/sales/day-end-report`): business-date picker, sales/payment/count cards,
|
||||
Income card (sales − expenses), the day's expenses, and a day-wise section (from/to, default last 30 days) with an
|
||||
income/sales/expenses line chart (`components/sales/DayEndIncomeChart.tsx`) and a daily table with totals (click a
|
||||
row to open that day). Admins follow the header branch switcher. `lib/api/day-end-report.ts`.
|
||||
- [x] PDFs (`lib/pdf/day-end-report-pdf.ts`): day-wise income (totals, chart, paginated daily table + totals row) and
|
||||
single day (figures, counts, expense list). **Deviation:** tables drawn with plain `jspdf` instead of adding
|
||||
`jspdf-autotable`; the chart is rendered offscreen in light colours for print.
|
||||
- [x] Sidebar entry (`sales.day-end-report`) and header title.
|
||||
- [x] **Sales table + profit/loss** (2026-10-08): the day view lists every sale (time, no., type, customer, paid by, total,
|
||||
paid, due, cost, profit; invoices link to their page) and ends with a statement — sales − cost of goods sold = gross
|
||||
profit − expenses = **Net profit** (green) or **Net loss** (red, shown as a positive amount labelled Loss;
|
||||
`profitOrLoss()` in `lib/format.ts`). Day-wise table, chart and both PDFs gain a Profit / loss column/line.
|
||||
|
||||
- [x] `types/expenses.ts`, `lib/api/expenses.ts` (`expensesApi`, `expenseSetupApi`).
|
||||
- [x] **Accounts → Expenses** (`app/dashboard/accounts/expenses`): list with period summary cards (posted, unpaid,
|
||||
outstanding, overdue), by-category breakdown, filters (status tabs, payment status incl. Overdue, category → title,
|
||||
date range, search), CSV export of all pages, row Post / Pay / Post & Pay. `new`, `[id]` (detail, payments, cancel
|
||||
with reason, delete draft) and `[id]/edit` (Draft only), sharing `components/accounts/ExpenseForm.tsx`.
|
||||
"Save & pay" lands on the detail page with `?pay=1`, which opens the payment dialog once.
|
||||
- [x] `components/accounts/ExpensePaymentDialog.tsx` reuses `PaymentLinesEditor` (split across cash/bank accounts) plus
|
||||
payment method and cheque no. A Draft goes through the single-transaction `post-and-pay` endpoint, unlike
|
||||
`PurchaseInvoicePaymentDialog`'s post-then-pay-per-line loop.
|
||||
- [x] **Accounts → Expense Setup** (`app/dashboard/accounts/expense-setup`): categories → titles, add category/title,
|
||||
edit (built-in names locked), GL code per title, activate/deactivate. Shows the Salaries/payroll double-count warning
|
||||
(plan §10 Q4). The expense form has an inline "Add title" for users with `accounts.expense-setup`.
|
||||
- [x] Sidebar (`AppSidebar.tsx`), header titles (`Header.tsx`) and the Accounts hub page list the two new screens.
|
||||
|
||||
## 0. Foundation
|
||||
- [x] **Transport: same-origin Next `rewrites()` proxy** (`next.config.ts`, `/api/*` → `BACKEND_ORIGIN`, default `http://localhost:5224`). `BACKEND_ORIGIN` in `.env.local` / `.env.local.example` — **not** `NEXT_PUBLIC_*`; the browser never sees the backend URL. Chosen over backend CORS because it makes the API same-origin, so CORS and the Secure/SameSite=Strict session cookies stop being problems at all. (`.gitignore`'s `.env*` was silently swallowing the example file — added a `!.env.local.example` negation.)
|
||||
- [x] **Typed API client rebuilt** (`lib/api-client.ts`, 2026-07-17) — recovered the pre-deletion version from git (`0e4bcf1^`) and adapted: relative `/api/v1` base, **`credentials: "include"`** (never present before), `ApiResult`/`ProblemDetails` imported from `@/types/common` rather than redeclared, `readCsrfToken()` for the eight `[ValidateCsrf]` auth actions. `ApiError`, `apiRequest`, `apiRequestWithETag`, `buildQuery`, `ifMatch`/`idempotencyKey` all carried over.
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Ban } from "lucide-react"
|
||||
|
||||
import { bankDepositsApi } from "@/lib/api/bank-deposits"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankDeposit } from "@/types/bank-deposits"
|
||||
|
||||
import { BankDepositStatusBadge } from "@/components/accounts/bank-deposit-badges"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function BankDepositDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const bankDepositId = Number(id)
|
||||
|
||||
const [deposit, setDeposit] = useState<BankDeposit | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(bankDepositId) ? loadError : `Invalid deposit id '${id}'.`
|
||||
const [cancelOpen, setCancelOpen] = useState(false)
|
||||
const [cancelReason, setCancelReason] = useState("")
|
||||
const [cancelError, setCancelError] = useState<string | null>(null)
|
||||
const [cancelling, setCancelling] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(bankDepositId)) return
|
||||
bankDepositsApi
|
||||
.get(bankDepositId)
|
||||
.then(setDeposit)
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [bankDepositId])
|
||||
|
||||
async function handleCancel() {
|
||||
if (!deposit) return
|
||||
if (!cancelReason.trim()) {
|
||||
setCancelError("Enter a reason")
|
||||
return
|
||||
}
|
||||
setCancelling(true)
|
||||
try {
|
||||
const cancelled = await bankDepositsApi.cancel(deposit.bankDepositId, cancelReason.trim())
|
||||
setDeposit(cancelled)
|
||||
setCancelOpen(false)
|
||||
toast.success("Deposit cancelled", `Reversing journal ${cancelled.glReversalJournalNo ?? "—"} — the cash is back in hand.`)
|
||||
} catch (err) {
|
||||
setCancelError(errorMessage(err))
|
||||
} finally {
|
||||
setCancelling(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackLink />
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!deposit) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<BackLink />
|
||||
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{deposit.docNo}</h1>
|
||||
<BankDepositStatusBadge status={deposit.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Cash of {formatReportDate(deposit.businessDate)} deposited to {deposit.toBankAccountName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{deposit.status === "Posted" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCancelReason("")
|
||||
setCancelError(null)
|
||||
setCancelOpen(true)
|
||||
}}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
Cancel deposit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">Amount</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", deposit.status === "Cancelled" ? "text-muted-foreground line-through" : "text-foreground")}>
|
||||
{formatAmount(deposit.amount)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">From</p>
|
||||
<p className="font-semibold text-foreground">{deposit.fromCashAccountName}</p>
|
||||
<p className="text-xs text-muted-foreground">GL {deposit.fromCashAccountCode}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">To</p>
|
||||
<p className="font-semibold text-foreground">{deposit.toBankAccountName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{[deposit.toBankName, deposit.toAccountNumber, `GL ${deposit.toBankAccountCode}`].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Detail label="Business date" value={formatReportDate(deposit.businessDate)} />
|
||||
<Detail label="Deposit date" value={formatReportDate(deposit.depositDate)} />
|
||||
<Detail label="Branch" value={deposit.branchName ?? `#${deposit.branchId}`} />
|
||||
<Detail label="Deposit slip no." value={deposit.depositSlipNo ?? "—"} />
|
||||
<Detail label="Deposited by" value={deposit.depositedBy ?? "—"} />
|
||||
<Detail label="Recorded" value={formatReportDate(deposit.createdAt)} />
|
||||
<Detail label="Ledger journal" value={deposit.glJournalNo ?? "—"} />
|
||||
{deposit.status === "Cancelled" && (
|
||||
<>
|
||||
<Detail label="Reversal journal" value={deposit.glReversalJournalNo ?? "—"} />
|
||||
<Detail label="Cancelled" value={formatReportDate(deposit.cancelledAt)} />
|
||||
<Detail label="Cancel reason" value={deposit.cancelReason ?? "—"} />
|
||||
</>
|
||||
)}
|
||||
{deposit.remarks && (
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<dt className="text-muted-foreground">Remarks</dt>
|
||||
<dd className="mt-0.5 whitespace-pre-wrap text-foreground">{deposit.remarks}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<Dialog open={cancelOpen} onOpenChange={setCancelOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel deposit {deposit.docNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A reversing journal entry will be posted (cash up, bank down) and {formatAmount(deposit.amount)} goes back into the cash in hand
|
||||
for {formatReportDate(deposit.businessDate)}. This cannot be undone — record a new deposit if it was only entered wrongly.
|
||||
</p>
|
||||
<Field data-invalid={!!cancelError}>
|
||||
<FieldLabel htmlFor="bd-cancel-reason" required>Reason</FieldLabel>
|
||||
<Textarea id="bd-cancel-reason" value={cancelReason} maxLength={250} rows={3} onChange={(e) => setCancelReason(e.target.value)} />
|
||||
<FieldError errors={[cancelError ? { message: cancelError } : undefined]} />
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelOpen(false)} disabled={cancelling}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
|
||||
{cancelling ? "Cancelling…" : "Cancel deposit"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link href="/dashboard/accounts/bank-deposits" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" />
|
||||
Bank Deposits
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className="mt-0.5 font-medium text-foreground">{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { bankDepositsApi } from "@/lib/api/bank-deposits"
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CashInHand, CreateBankDepositRequest, maskAccountNumber } from "@/types/bank-deposits"
|
||||
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
|
||||
import { Branch } from "@/types/hrm"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { localToday } from "@/components/sales/PaymentLinesEditor"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Field, FieldDescription, 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"
|
||||
|
||||
type Errors = Partial<Record<"branch" | "businessDate" | "depositDate" | "from" | "to" | "amount", string>>
|
||||
|
||||
function addDays(iso: string, days: number) {
|
||||
const d = new Date(`${iso}T00:00:00`)
|
||||
d.setDate(d.getDate() + days)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Record till cash taken to the bank: pick the day, see its cash position, choose the cash and bank accounts. */
|
||||
export default function NewBankDepositPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, loading: authLoading, branchId: myBranchId, activeBranchId, activeBranchName, branchingEnabled } = useAuth()
|
||||
const today = localToday()
|
||||
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [accountsError, setAccountsError] = useState<string | null>(null)
|
||||
const [position, setPosition] = useState<CashInHand | null>(null)
|
||||
const [positionError, setPositionError] = useState<string | null>(null)
|
||||
|
||||
const [branchPickId, setBranchPickId] = useState<number | null>(null)
|
||||
const [businessDate, setBusinessDate] = useState(today)
|
||||
const [depositDate, setDepositDate] = useState(today)
|
||||
const [fromAccountId, setFromAccountId] = useState<number | null>(null)
|
||||
const [toAccountId, setToAccountId] = useState<number | null>(null)
|
||||
const [amount, setAmount] = useState("")
|
||||
const [amountTouched, setAmountTouched] = useState(false)
|
||||
const [depositSlipNo, setDepositSlipNo] = useState("")
|
||||
const [depositedBy, setDepositedBy] = useState("")
|
||||
const [remarks, setRemarks] = useState("")
|
||||
const [errors, setErrors] = useState<Errors>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Same branch rules as the Expense form: a branch user is always their own branch; an admin uses the
|
||||
// header's branch, or picks one here. With branching off the server uses the single default branch.
|
||||
const lockedBranchId = isAdmin ? activeBranchId : myBranchId
|
||||
const showBranchPicker = branchingEnabled && isAdmin && !lockedBranchId
|
||||
const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchPickId) : myBranchId) : null
|
||||
const needsBranch = branchingEnabled && isAdmin && effectiveBranchId === null
|
||||
|
||||
const cashAccounts = accounts?.filter((a) => a.accountType === CashBankAccountType.Cash) ?? []
|
||||
const bankAccounts = accounts?.filter((a) => a.accountType === CashBankAccountType.Bank) ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (branchingEnabled && isAdmin) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [branchingEnabled, isAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (accounts !== null) return
|
||||
bankAccountsApi
|
||||
.list("Both")
|
||||
.then((rows) => {
|
||||
setAccounts(rows)
|
||||
setAccountsError(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
setAccounts([])
|
||||
setAccountsError(errorMessage(err))
|
||||
})
|
||||
}, [accounts])
|
||||
|
||||
useEffect(() => {
|
||||
if (authLoading || needsBranch || !businessDate) return
|
||||
let stale = false
|
||||
bankDepositsApi
|
||||
.cashInHand({ date: businessDate, branchId: effectiveBranchId })
|
||||
.then((res) => {
|
||||
if (stale) return
|
||||
setPosition(res)
|
||||
setPositionError(null)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (stale) return
|
||||
setPosition(null)
|
||||
setPositionError(errorMessage(err))
|
||||
})
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [authLoading, needsBranch, businessDate, effectiveBranchId])
|
||||
|
||||
// Preselect the configured cash account (and the only bank account, when there is just one).
|
||||
const defaultCashCode = position?.defaultCashAccountCode ?? null
|
||||
if (fromAccountId === null && cashAccounts.length > 0) {
|
||||
const preferred = cashAccounts.find((a) => a.glAccountCode === defaultCashCode) ?? (cashAccounts.length === 1 ? cashAccounts[0] : null)
|
||||
if (preferred) setFromAccountId(preferred.accountId)
|
||||
}
|
||||
if (toAccountId === null && bankAccounts.length === 1) setToAccountId(bankAccounts[0].accountId)
|
||||
|
||||
// The amount follows what is available until the user types their own.
|
||||
const available = position?.availableToDeposit ?? 0
|
||||
const suggested = position ? available.toFixed(2) : ""
|
||||
if (!amountTouched && amount !== suggested) setAmount(suggested)
|
||||
|
||||
function changeBusinessDate(value: string) {
|
||||
setBusinessDate(value)
|
||||
// Deposit date follows the business date unless it would fall before it.
|
||||
if (!depositDate || depositDate < value) setDepositDate(value)
|
||||
}
|
||||
|
||||
function validate(): Errors {
|
||||
const next: Errors = {}
|
||||
const value = Number(amount)
|
||||
if (needsBranch) next.branch = "Select the branch whose cash is being deposited"
|
||||
if (!businessDate) next.businessDate = "Select the business date"
|
||||
else if (businessDate > today) next.businessDate = "The business date cannot be in the future"
|
||||
else if (position?.trackingStartDate && businessDate < position.trackingStartDate)
|
||||
next.businessDate = `Cash in hand is tracked from ${formatReportDate(position.trackingStartDate)}`
|
||||
if (!depositDate) next.depositDate = "Select the deposit date"
|
||||
else if (businessDate && depositDate < businessDate) next.depositDate = "Cannot be before the business date"
|
||||
else if (depositDate > addDays(today, 1)) next.depositDate = "Cannot be in the future"
|
||||
if (!fromAccountId) next.from = "Select the cash account the money leaves"
|
||||
if (!toAccountId) next.to = "Select the bank account it goes into"
|
||||
if (!amount || Number.isNaN(value) || value <= 0) next.amount = "Enter an amount greater than zero"
|
||||
else if (position && value > available + 0.0001) next.amount = `Only ${formatAmount(available)} is available to deposit for this day`
|
||||
return next
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const nextErrors = validate()
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
const request: CreateBankDepositRequest = {
|
||||
branchId: effectiveBranchId ?? 0,
|
||||
businessDate,
|
||||
depositDate,
|
||||
amount: Number(amount),
|
||||
fromGlCashAccountId: fromAccountId!,
|
||||
toGlBankAccountId: toAccountId!,
|
||||
depositSlipNo: depositSlipNo.trim() || undefined,
|
||||
depositedBy: depositedBy.trim() || undefined,
|
||||
remarks: remarks.trim() || undefined,
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const saved = await bankDepositsApi.create(request)
|
||||
toast.success("Deposit recorded", `${saved.docNo} — ${formatAmount(saved.amount)} to ${saved.toBankAccountName} (journal ${saved.glJournalNo ?? "—"})`)
|
||||
router.push(`/dashboard/accounts/bank-deposits/${saved.bankDepositId}`)
|
||||
} catch (err) {
|
||||
toast.error("Could not record the deposit", errorMessage(err))
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (authLoading) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link href="/dashboard/accounts/bank-deposits" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" />
|
||||
Bank Deposits
|
||||
</Link>
|
||||
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Bank Deposit</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Deposit the day’s till cash to a bank account. It is posted to the ledger straight away (bank up, cash down).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_20rem]">
|
||||
<div className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{branchingEnabled && isAdmin && (
|
||||
<Field data-invalid={!!errors.branch}>
|
||||
<FieldLabel required>Branch</FieldLabel>
|
||||
{showBranchPicker ? (
|
||||
<Select<number | null> value={branchPickId} onValueChange={(v) => setBranchPickId(v)}>
|
||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.branch}>
|
||||
<SelectValue placeholder="Select 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>
|
||||
)}
|
||||
<FieldError errors={[errors.branch ? { message: errors.branch } : undefined]} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field data-invalid={!!errors.businessDate}>
|
||||
<FieldLabel htmlFor="bd-business-date" required>Business date</FieldLabel>
|
||||
<Input
|
||||
id="bd-business-date"
|
||||
type="date"
|
||||
max={today}
|
||||
min={position?.trackingStartDate ?? undefined}
|
||||
value={businessDate}
|
||||
onChange={(e) => changeBusinessDate(e.target.value)}
|
||||
aria-invalid={!!errors.businessDate}
|
||||
/>
|
||||
<FieldDescription>The day whose cash is being deposited.</FieldDescription>
|
||||
<FieldError errors={[errors.businessDate ? { message: errors.businessDate } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.depositDate}>
|
||||
<FieldLabel htmlFor="bd-deposit-date" required>Deposit date</FieldLabel>
|
||||
<Input
|
||||
id="bd-deposit-date"
|
||||
type="date"
|
||||
min={businessDate || undefined}
|
||||
max={addDays(today, 1)}
|
||||
value={depositDate}
|
||||
onChange={(e) => setDepositDate(e.target.value)}
|
||||
aria-invalid={!!errors.depositDate}
|
||||
/>
|
||||
<FieldDescription>When the bank received it — also the ledger entry date.</FieldDescription>
|
||||
<FieldError errors={[errors.depositDate ? { message: errors.depositDate } : undefined]} />
|
||||
</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={() => setAccounts(null)}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.from}>
|
||||
<FieldLabel htmlFor="bd-from" required>From cash account</FieldLabel>
|
||||
<Select<number | null> value={fromAccountId} onValueChange={(v) => setFromAccountId(v)}>
|
||||
<SelectTrigger id="bd-from" className="h-11! w-full text-base" aria-invalid={!!errors.from}>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select cash account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cashAccounts.map((a) => (
|
||||
<SelectItem key={a.accountId} value={a.accountId} className="text-base">
|
||||
{a.accountName} <span className="text-muted-foreground">({a.glAccountCode})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{accounts !== null && cashAccounts.length === 0 && !accountsError && (
|
||||
<FieldDescription>No cash accounts in the ledger — add one under Accounts → Cash / Bank Accounts.</FieldDescription>
|
||||
)}
|
||||
<FieldError errors={[errors.from ? { message: errors.from } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.to}>
|
||||
<FieldLabel htmlFor="bd-to" required>To bank account</FieldLabel>
|
||||
<Select<number | null> value={toAccountId} onValueChange={(v) => setToAccountId(v)}>
|
||||
<SelectTrigger id="bd-to" className="h-11! w-full text-base" aria-invalid={!!errors.to}>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{bankAccounts.map((a) => (
|
||||
<SelectItem key={a.accountId} value={a.accountId} className="text-base">
|
||||
{a.accountName}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
{[a.bankName, maskAccountNumber(a.accountNumber)].filter(Boolean).join(" · ")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{accounts !== null && bankAccounts.length === 0 && !accountsError && (
|
||||
<FieldDescription>No bank accounts in the ledger — add one under Accounts → Cash / Bank Accounts.</FieldDescription>
|
||||
)}
|
||||
<FieldError errors={[errors.to ? { message: errors.to } : undefined]} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="bd-amount" required>Amount</FieldLabel>
|
||||
<Input
|
||||
id="bd-amount"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
setAmountTouched(true)
|
||||
setAmount(e.target.value)
|
||||
}}
|
||||
aria-invalid={!!errors.amount}
|
||||
className="text-base tabular-nums"
|
||||
/>
|
||||
<FieldDescription>
|
||||
Defaults to everything available.
|
||||
{amountTouched && position && (
|
||||
<>
|
||||
{" "}
|
||||
<button type="button" className="font-medium text-primary hover:underline" onClick={() => setAmountTouched(false)}>
|
||||
Use {formatAmount(available)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</FieldDescription>
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bd-slip">Deposit slip no. (optional)</FieldLabel>
|
||||
<Input id="bd-slip" value={depositSlipNo} maxLength={50} onChange={(e) => setDepositSlipNo(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bd-by">Deposited by (optional)</FieldLabel>
|
||||
<Input id="bd-by" value={depositedBy} maxLength={150} onChange={(e) => setDepositedBy(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bd-remarks">Remarks (optional)</FieldLabel>
|
||||
<Textarea id="bd-remarks" value={remarks} maxLength={500} rows={2} onChange={(e) => setRemarks(e.target.value)} />
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Link href="/dashboard/accounts/bank-deposits" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" onClick={submit} disabled={saving || needsBranch}>
|
||||
{saving ? "Recording…" : "Record deposit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CashPositionPanel position={position} error={positionError} needsBranch={needsBranch} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CashPositionPanel({ position, error, needsBranch }: { position: CashInHand | null; error: string | null; needsBranch: boolean }) {
|
||||
return (
|
||||
<aside className="flex h-fit flex-col gap-3 rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
||||
<h2 className="text-base font-semibold text-foreground">Cash position</h2>
|
||||
{needsBranch ? (
|
||||
<p className="text-sm text-muted-foreground">Select a branch to see its cash in hand.</p>
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
) : !position ? (
|
||||
<Skeleton className="h-48 w-full" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">{formatReportDate(position.position.date)}</p>
|
||||
<dl className="flex flex-col gap-2 text-sm">
|
||||
<Row label="Opening cash in hand" value={position.position.opening} />
|
||||
<Row label="Cash in" value={position.position.cashIn} />
|
||||
<Row label="Cash out (expenses)" value={-position.position.cashOut} />
|
||||
<Row label="Already deposited" value={-position.position.deposited} />
|
||||
<div className="border-t pt-2">
|
||||
<Row label="Cash in hand" value={position.position.closing} strong />
|
||||
</div>
|
||||
</dl>
|
||||
<div className="rounded-xl bg-primary/10 p-3">
|
||||
<p className="text-sm text-muted-foreground">Available to deposit</p>
|
||||
<p className="text-2xl font-bold tabular-nums text-primary">{formatAmount(position.availableToDeposit)}</p>
|
||||
{position.availableToDeposit < position.position.closing && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Lower than the day’s cash in hand: later days have already banked part of it.</p>
|
||||
)}
|
||||
</div>
|
||||
{position.trackingStartDate && (
|
||||
<p className="text-xs text-muted-foreground">Cash in hand is counted from {formatReportDate(position.trackingStartDate)}.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value, strong }: { label: string; value: number; strong?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className={cn(strong ? "font-semibold text-foreground" : "text-muted-foreground")}>{label}</dt>
|
||||
<dd className={cn("tabular-nums", strong ? "font-bold text-foreground" : "font-medium text-foreground")}>{formatAmount(value)}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { CalendarDays, Coins, Download, FileText, Landmark, PiggyBank, Plus, Wallet } from "lucide-react"
|
||||
|
||||
import { bankDepositsApi } from "@/lib/api/bank-deposits"
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
import { downloadCsv } from "@/lib/csv-export"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
|
||||
import {
|
||||
BankDeposit,
|
||||
BankDepositListFilterParams,
|
||||
BankDepositStatus,
|
||||
CashInHand,
|
||||
CashInHandDaily,
|
||||
maskAccountNumber,
|
||||
} from "@/types/bank-deposits"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { BankDepositStatusBadge } from "@/components/accounts/bank-deposit-badges"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { DateRangePicker } from "@/components/ui/date-picker"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type View = "deposits" | "cash"
|
||||
type StatusFilter = BankDepositStatus | "All"
|
||||
type DateRange = "Month" | "Today" | "7d" | "30d" | "All" | "Custom"
|
||||
type CustomRange = { from: Date | undefined; to?: Date | undefined }
|
||||
|
||||
const statusOptions: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All statuses" },
|
||||
{ value: "Posted", label: "Posted" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
]
|
||||
const dateRanges: { value: DateRange; label: string }[] = [
|
||||
{ value: "Month", label: "This month" },
|
||||
{ value: "Today", label: "Today" },
|
||||
{ value: "7d", label: "7 days" },
|
||||
{ value: "30d", label: "30 days" },
|
||||
{ value: "All", label: "All time" },
|
||||
{ value: "Custom", label: "Custom range" },
|
||||
]
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
/** The cash-in-hand endpoint serves at most 92 days. */
|
||||
const MAX_CASH_DAYS = 92
|
||||
|
||||
function toIsoDate(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function addDays(iso: string, days: number) {
|
||||
const d = new Date(`${iso}T00:00:00`)
|
||||
d.setDate(d.getDate() + days)
|
||||
return toIsoDate(d)
|
||||
}
|
||||
|
||||
/** Inclusive business-date bounds ending today, as on the Expenses list. */
|
||||
function dateRangeBounds(range: DateRange, custom: CustomRange | undefined): { from?: string; to?: string } {
|
||||
const today = new Date()
|
||||
if (range === "Custom") {
|
||||
if (!custom?.from) return {}
|
||||
return { from: toIsoDate(custom.from), to: toIsoDate(custom.to ?? custom.from) }
|
||||
}
|
||||
if (range === "All") return {}
|
||||
if (range === "Month") return { from: toIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), to: toIsoDate(today) }
|
||||
const days = range === "Today" ? 1 : range === "7d" ? 7 : 30
|
||||
const from = new Date(today)
|
||||
from.setDate(today.getDate() - (days - 1))
|
||||
return { from: toIsoDate(from), to: toIsoDate(today) }
|
||||
}
|
||||
|
||||
/** The cash view needs a bounded window: the filter's, else the last 30 days; never over 92 days (keeps the end). */
|
||||
function cashWindow(from: string | undefined, to: string | undefined): { from: string; to: string } {
|
||||
const end = to ?? toIsoDate(new Date())
|
||||
let start = from ?? addDays(end, -29)
|
||||
if (addDays(start, MAX_CASH_DAYS - 1) < end) start = addDays(end, -(MAX_CASH_DAYS - 1))
|
||||
return { from: start, to: end }
|
||||
}
|
||||
|
||||
function bankLabel(d: { toBankAccountName: string; toBankName: string | null; toAccountNumber: string | null }) {
|
||||
return [d.toBankName, maskAccountNumber(d.toAccountNumber)].filter(Boolean).join(" · ")
|
||||
}
|
||||
|
||||
export default function BankDepositsPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
|
||||
const [view, setView] = useState<View>("deposits")
|
||||
const [rows, setRows] = useState<BankDeposit[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [totalAmount, setTotalAmount] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[]>([])
|
||||
const [todayCash, setTodayCash] = useState<CashInHand | null>(null)
|
||||
const [daily, setDaily] = useState<CashInHandDaily | null>(null)
|
||||
const [dailyError, setDailyError] = useState<string | null>(null)
|
||||
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [bankAccountId, setBankAccountId] = useState<number | null>(null)
|
||||
const [dateRange, setDateRange] = useState<DateRange>("Month")
|
||||
const [customRange, setCustomRange] = useState<CustomRange | undefined>(undefined)
|
||||
const { from, to } = dateRangeBounds(dateRange, customRange)
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [rowsPerPage, setRowsPerPage] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
// Admins bypass branch scoping server-side, so the header's branch switcher is sent as the filter
|
||||
// (the totals come from the server, so filtering client-side would leave them wrong).
|
||||
const branchId = isAdmin && branchingEnabled ? activeBranchId : null
|
||||
const showBranch = isAdmin && branchingEnabled && !activeBranchId
|
||||
|
||||
useEffect(() => {
|
||||
bankAccountsApi.list(CashBankAccountType.Bank).then(setBankAccounts).catch(() => setBankAccounts([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
const filters: BankDepositListFilterParams = useMemo(
|
||||
() => ({
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
bankAccountId: bankAccountId ?? undefined,
|
||||
branchId: branchId ?? undefined,
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
[query, status, bankAccountId, branchId, from, to]
|
||||
)
|
||||
|
||||
const pagingKey = `${JSON.stringify(filters)}|${rowsPerPage}`
|
||||
const [paging, setPaging] = useState({ key: "", page: 1, pageSize: rowsPerPage })
|
||||
const { page, pageSize } = paging.key === pagingKey ? paging : { page: 1, pageSize: rowsPerPage }
|
||||
const setPage = (p: number) => setPaging({ key: pagingKey, page: p, pageSize })
|
||||
|
||||
useEffect(() => {
|
||||
bankDepositsApi
|
||||
.list({ ...filters, page, pageSize })
|
||||
.then((res) => {
|
||||
setError(null)
|
||||
setRows(res.items)
|
||||
setPagination(res.pagination)
|
||||
setTotalAmount(res.totalAmount)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setLoadingMore(false))
|
||||
}, [filters, page, pageSize])
|
||||
|
||||
useEffect(() => {
|
||||
bankDepositsApi.cashInHand({ branchId }).then(setTodayCash).catch(() => setTodayCash(null))
|
||||
}, [branchId])
|
||||
|
||||
const cashRange = cashWindow(from, to)
|
||||
useEffect(() => {
|
||||
if (view !== "cash") return
|
||||
bankDepositsApi
|
||||
.cashInHandDaily({ from: cashRange.from, to: cashRange.to, branchId })
|
||||
.then((res) => {
|
||||
setDaily(res)
|
||||
setDailyError(null)
|
||||
})
|
||||
.catch((err) => setDailyError(errorMessage(err)))
|
||||
}, [view, cashRange.from, cashRange.to, branchId])
|
||||
|
||||
function openDeposit(row: BankDeposit) {
|
||||
router.push(`/dashboard/accounts/bank-deposits/${row.bankDepositId}`)
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setLoadingMore(true)
|
||||
setPaging({ key: pagingKey, page: 1, pageSize: pageSize + rowsPerPage })
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const all: BankDeposit[] = []
|
||||
for (let p = 1; ; p++) {
|
||||
const res = await bankDepositsApi.list({ ...filters, page: p, pageSize: 200 })
|
||||
all.push(...res.items)
|
||||
if (p >= res.pagination.totalPages) break
|
||||
}
|
||||
downloadCsv(`bank-deposits-${from ?? "all"}-to-${to ?? "all"}.csv`, all, [
|
||||
{ header: "Doc no", value: (r) => r.docNo },
|
||||
{ header: "Business date", value: (r) => r.businessDate },
|
||||
{ header: "Deposit date", value: (r) => r.depositDate },
|
||||
{ header: "Branch", value: (r) => r.branchName },
|
||||
{ header: "From cash account", value: (r) => r.fromCashAccountName },
|
||||
{ header: "Bank account", value: (r) => r.toBankAccountName },
|
||||
{ header: "Bank", value: (r) => r.toBankName },
|
||||
{ header: "Account no", value: (r) => r.toAccountNumber },
|
||||
{ header: "Slip no", value: (r) => r.depositSlipNo },
|
||||
{ header: "Deposited by", value: (r) => r.depositedBy },
|
||||
{ header: "Amount", value: (r) => r.amount.toFixed(2) },
|
||||
{ header: "Status", value: (r) => r.status },
|
||||
{ header: "GL journal", value: (r) => r.glJournalNo },
|
||||
{ header: "Reversal journal", value: (r) => r.glReversalJournalNo },
|
||||
])
|
||||
} catch (err) {
|
||||
toast.error("Could not export deposits", errorMessage(err))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const advancedActive = bankAccountId !== null
|
||||
const hasFilters = status !== "All" || advancedActive || query.length > 0 || dateRange !== "All"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Bank Deposits</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Till cash taken to the bank after the day ends. Only cash is deposited — card, transfer and cheque receipts never sit in the till.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Button variant="outline" size="lg" onClick={handleExport} disabled={exporting} className="w-full sm:w-auto">
|
||||
<Download className="size-5" />
|
||||
{exporting ? "Exporting…" : "Export CSV"}
|
||||
</Button>
|
||||
<Link href="/dashboard/accounts/bank-deposits/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Deposit
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Deposited (filtered)" value={formatAmount(totalAmount)} icon={Landmark} />
|
||||
{todayCash ? (
|
||||
<>
|
||||
<StatCard label="Cash in hand now" value={formatAmount(todayCash.position.closing)} icon={Wallet} />
|
||||
<StatCard label="Net cash today" value={formatAmount(todayCash.position.netCash)} icon={Coins} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="inline-flex w-fit rounded-lg border bg-muted p-1" role="tablist">
|
||||
{(
|
||||
[
|
||||
["deposits", "Deposits"],
|
||||
["cash", "Cash in hand by day"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={view === value}
|
||||
onClick={() => setView(value)}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
view === value ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<FilterBar
|
||||
className="border-b px-4 py-4"
|
||||
searchFirst
|
||||
search={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchPlaceholder="Filter by doc no., slip no. or depositor"
|
||||
advancedActive={advancedActive}
|
||||
onClearAdvanced={() => setBankAccountId(null)}
|
||||
advanced={
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bd-filter-bank">Bank account</FieldLabel>
|
||||
<Select<number | null> value={bankAccountId} onValueChange={(v) => setBankAccountId(v)}>
|
||||
<SelectTrigger id="bd-filter-bank"><SelectValue placeholder="All bank accounts" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>All bank accounts</SelectItem>
|
||||
{bankAccounts.map((a) => (
|
||||
<SelectItem key={a.accountId} value={a.accountId}>
|
||||
{a.accountName}
|
||||
{a.accountNumber ? ` · ${maskAccountNumber(a.accountNumber)}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-10! w-36 text-sm" aria-label="Status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select<DateRange> value={dateRange} onValueChange={(v) => setDateRange(v ?? "Month")}>
|
||||
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Business date range">
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dateRanges.map((r) => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{dateRange === "Custom" && (
|
||||
<DateRangePicker value={customRange} onChange={setCustomRange} placeholder="Pick from – to dates" className="sm:w-72" />
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{view === "cash" ? (
|
||||
<CashByDay daily={daily} error={dailyError} />
|
||||
) : (
|
||||
<>
|
||||
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
{!error && rows === null && (
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
|
||||
<PiggyBank className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No deposits match your filters." : "No deposits yet."}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && rows.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
key={row.bankDepositId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={() => openDeposit(row)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") openDeposit(row)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{row.docNo}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{row.toBankAccountName}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">{bankLabel(row)}</p>
|
||||
</div>
|
||||
<BankDepositStatusBadge status={row.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Business date</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(row.businessDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Amount</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{formatAmount(row.amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Deposited on</p>
|
||||
<p className="font-medium text-foreground">{formatReportDate(row.depositDate)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Slip no.</p>
|
||||
<p className="font-medium text-foreground">{row.depositSlipNo ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center justify-between rounded-xl border bg-muted/40 px-4 py-3 text-sm">
|
||||
<span className="text-muted-foreground">Total ({pagination?.totalItems ?? rows.length})</span>
|
||||
<span className="font-semibold tabular-nums text-foreground">{formatAmount(totalAmount)}</span>
|
||||
</div>
|
||||
|
||||
{pagination && rows.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={loadingMore}>
|
||||
{loadingMore ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="mx-4 my-4 hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Deposit</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Business date</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Deposited on</TableHead>
|
||||
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Slip no.</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Amount</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><span className="sr-only">Open</span></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.bankDepositId} className="cursor-pointer" onClick={() => openDeposit(row)}>
|
||||
<TableCell className="px-3 py-3.5 font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<PiggyBank className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p>{row.docNo}</p>
|
||||
{row.depositedBy && <p className="text-xs font-normal text-muted-foreground">By {row.depositedBy}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatReportDate(row.businessDate)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatReportDate(row.depositDate)}</TableCell>
|
||||
{showBranch && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
||||
{row.branchName ?? `#${row.branchId}`}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<p>{row.toBankAccountName}</p>
|
||||
<p className="text-xs text-muted-foreground">{bankLabel(row)}</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.depositSlipNo ?? "—"}</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5 text-right font-semibold tabular-nums", row.status === "Cancelled" && "text-muted-foreground line-through")}>
|
||||
{formatAmount(row.amount)}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5"><BankDepositStatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Open ${row.docNo}`} title="Open">
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={showBranch ? 6 : 5} className="px-3 py-3 text-sm text-muted-foreground">
|
||||
Total of {pagination?.totalItems ?? rows.length} deposit{(pagination?.totalItems ?? rows.length) === 1 ? "" : "s"}
|
||||
{status === "All" ? " (cancelled ones included)" : ""}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalAmount)}</TableCell>
|
||||
<TableCell colSpan={2} />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden px-4 pb-4 md:block">
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} pageSizeOptions={PAGE_SIZE_OPTIONS} onPageSizeChange={setRowsPerPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Day-wise till cash: Opening · Cash in · Cash out · Deposited · Closing, newest day first. */
|
||||
function CashByDay({ daily, error }: { daily: CashInHandDaily | null; error: string | null }) {
|
||||
if (error) return <div className="mx-4 my-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
if (!daily) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const days = [...daily.days].reverse()
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatReportDate(daily.from)} – {formatReportDate(daily.to)} (at most {MAX_CASH_DAYS} days)
|
||||
{daily.trackingStartDate ? ` · cash in hand counted from ${formatReportDate(daily.trackingStartDate)}` : ""}
|
||||
</p>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Opening</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Cash in</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Cash out</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Deposited</TableHead>
|
||||
<TableHead className="h-11 px-3 text-right text-sm">Cash in hand</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{days.map((d) => (
|
||||
<TableRow key={d.date}>
|
||||
<TableCell className="px-3 py-2.5">{formatReportDate(d.date)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(d.opening, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(d.cashIn, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(d.cashOut, true)}</TableCell>
|
||||
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(d.deposited, true)}</TableCell>
|
||||
<TableCell className={cn("px-3 py-2.5 text-right font-semibold tabular-nums", d.closing > 0 && "text-warning")}>
|
||||
{formatAmount(d.closing)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Lock, Pencil, Plus, Tags } from "lucide-react"
|
||||
|
||||
import { expenseSetupApi } from "@/lib/api/expenses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { EntityStatus } from "@/types/common"
|
||||
import { ExpenseCategory, ExpenseTitle } from "@/types/expenses"
|
||||
|
||||
import { AddTitleDialog } from "@/components/accounts/ExpenseForm"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
/** What the edit dialog is editing — a category (no GL code) or a title. */
|
||||
type Editing = { kind: "category"; row: ExpenseCategory } | { kind: "title"; row: ExpenseTitle } | null
|
||||
|
||||
export default function ExpenseSetupPage() {
|
||||
const [categories, setCategories] = useState<ExpenseCategory[] | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [addTitleOpen, setAddTitleOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Editing>(null)
|
||||
|
||||
function load() {
|
||||
expenseSetupApi
|
||||
.list(true)
|
||||
.then((rows) => {
|
||||
setCategories(rows)
|
||||
setSelectedId((prev) => prev ?? rows[0]?.expenseCategoryId ?? null)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}
|
||||
|
||||
useEffect(load, [])
|
||||
|
||||
const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Expense Setup</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && categories === null && <Skeleton className="h-80 w-full" />}
|
||||
|
||||
{categories && (
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[16rem_1fr]">
|
||||
<nav className="flex gap-2 overflow-x-auto lg:flex-col" aria-label="Expense categories">
|
||||
{categories.map((c) => (
|
||||
<button
|
||||
key={c.expenseCategoryId}
|
||||
onClick={() => setSelectedId(c.expenseCategoryId)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2 rounded-xl border px-4 py-3 text-left text-sm transition-colors",
|
||||
c.expenseCategoryId === selectedId ? "border-primary bg-primary/5 text-foreground" : "bg-card text-muted-foreground hover:text-foreground",
|
||||
c.status === "Inactive" && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{c.name}</span>
|
||||
<span className="text-xs tabular-nums">{c.titles.length}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{selected && (
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<div className="flex flex-col gap-3 border-b px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tags className="size-5 text-primary" />
|
||||
<h2 className="text-lg font-semibold text-foreground">{selected.name}</h2>
|
||||
<span className="text-xs text-muted-foreground">{selected.code}</span>
|
||||
{selected.isSystem && <SystemBadge />}
|
||||
{selected.status === "Inactive" && <InactiveBadge />}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing({ kind: "category", row: selected })}>
|
||||
<Pencil className="size-4" />
|
||||
Edit category
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setAddTitleOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Add title
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected.code === "SALARIES" && (
|
||||
<p className="mx-4 mt-4 rounded-lg border border-warning/30 bg-warning/5 p-3 text-sm text-warning">
|
||||
Payroll runs in HRM don't post to the ledger yet. If they start to, record salaries there only, or they will be counted twice.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selected.titles.length === 0 ? (
|
||||
<p className="px-4 py-10 text-center text-sm text-muted-foreground">No titles yet — add the first one.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-4 text-sm">Title</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">GL expense account</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Status</TableHead>
|
||||
<TableHead className="h-11 px-4 text-right text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selected.titles.map((t) => (
|
||||
<TableRow key={t.expenseTitleId} className={cn(t.status === "Inactive" && "opacity-60")}>
|
||||
<TableCell className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{t.name}
|
||||
{t.isSystem && <Lock className="size-3.5 text-muted-foreground" aria-label="Built-in" />}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 tabular-nums">{t.glExpenseAccountCode}</TableCell>
|
||||
<TableCell className="px-4 py-3">{t.status === "Active" ? "Active" : <InactiveBadge />}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing({ kind: "title", row: t })}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<AddTitleDialog
|
||||
open={addTitleOpen}
|
||||
onOpenChange={setAddTitleOpen}
|
||||
category={selected}
|
||||
onCreated={(title) =>
|
||||
setCategories((prev) =>
|
||||
prev?.map((c) => (c.expenseCategoryId === title.expenseCategoryId ? { ...c, titles: [...c.titles, title] } : c)) ?? prev
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<EditDialog editing={editing} onClose={() => setEditing(null)} onSaved={load} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemBadge() {
|
||||
return (
|
||||
<Badge variant="outline" className="h-5 gap-1 border-transparent bg-muted px-2 text-xs text-muted-foreground">
|
||||
<Lock className="size-3" />
|
||||
Built-in
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function InactiveBadge() {
|
||||
return (
|
||||
<Badge variant="outline" className="h-5 border-transparent bg-muted px-2 text-xs text-muted-foreground">
|
||||
Inactive
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: () => void; onSaved: () => void }) {
|
||||
const [name, setName] = useState("")
|
||||
const [glCode, setGlCode] = useState("")
|
||||
const [active, setActive] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs.
|
||||
const key = editing ? `${editing.kind}-${editing.kind === "category" ? editing.row.expenseCategoryId : editing.row.expenseTitleId}` : null
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (key !== resetFor) {
|
||||
setResetFor(key)
|
||||
if (editing) {
|
||||
setName(editing.row.name)
|
||||
setGlCode(editing.kind === "title" ? editing.row.glExpenseAccountCode : "")
|
||||
setActive(editing.row.status === "Active")
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!editing) return null
|
||||
const isSystem = editing.row.isSystem
|
||||
|
||||
async function submit() {
|
||||
if (!editing) return
|
||||
if (!name.trim()) return setError("Enter a name")
|
||||
if (editing.kind === "title" && !glCode.trim()) return setError("Enter the GL expense account code")
|
||||
const status: EntityStatus = active ? "Active" : "Inactive"
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editing.kind === "category") {
|
||||
await expenseSetupApi.updateCategory(editing.row.expenseCategoryId, { name: name.trim(), status })
|
||||
} else {
|
||||
await expenseSetupApi.updateTitle(editing.row.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status })
|
||||
}
|
||||
toast.success("Saved", name.trim())
|
||||
onSaved()
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {editing.kind === "category" ? "category" : "title"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-name" required>Name</FieldLabel>
|
||||
<Input id="edit-name" value={name} maxLength={100} disabled={isSystem} onChange={(e) => setName(e.target.value)} />
|
||||
{isSystem && <FieldDescription>Built-in names can't be changed.</FieldDescription>}
|
||||
</Field>
|
||||
{editing.kind === "title" && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="edit-gl" required>GL expense account code</FieldLabel>
|
||||
<Input id="edit-gl" value={glCode} maxLength={20} onChange={(e) => setGlCode(e.target.value)} />
|
||||
<FieldDescription>Used for expenses posted from now on; already-posted expenses keep their account.</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
<Field orientation="horizontal">
|
||||
<Switch id="edit-active" checked={active} onCheckedChange={setActive} />
|
||||
<FieldLabel htmlFor="edit-active">Active — offered on new expenses</FieldLabel>
|
||||
</Field>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { expensesApi } from "@/lib/api/expenses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { Expense } from "@/types/expenses"
|
||||
|
||||
import { ExpenseForm } from "@/components/accounts/ExpenseForm"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
|
||||
export default function EditExpensePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const router = useRouter()
|
||||
const { id } = use(params)
|
||||
const expenseId = Number(id)
|
||||
|
||||
const [loaded, setLoaded] = useState<{ expense: Expense; etag: string | null } | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(expenseId) ? loadError : `Invalid expense id '${id}'.`
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(expenseId)) return
|
||||
expensesApi
|
||||
.get(expenseId)
|
||||
.then((res) => {
|
||||
if (res.data.status !== "Draft") {
|
||||
// Only drafts are editable — send anything else back to its detail page.
|
||||
router.replace(`/dashboard/accounts/expenses/${expenseId}`)
|
||||
return
|
||||
}
|
||||
setLoaded({ expense: res.data, etag: res.etag })
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [expenseId, id, router])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Edit Expense {loaded?.expense.docNo ?? ""}</h1>
|
||||
<p className="text-base text-muted-foreground">Drafts stay editable until they are posted.</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && !loaded && <Skeleton className="h-96 w-full" />}
|
||||
{loaded && (
|
||||
<ExpenseForm
|
||||
existing={loaded}
|
||||
cancelHref={`/dashboard/accounts/expenses/${expenseId}`}
|
||||
onSaved={(expense, action) =>
|
||||
router.push(`/dashboard/accounts/expenses/${expense.expenseId}${action === "pay" ? "?pay=1" : ""}`)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
"use client"
|
||||
|
||||
import { use, useCallback, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ArrowLeft, Ban, CheckCircle2, CircleDollarSign, Pencil, Trash2 } from "lucide-react"
|
||||
|
||||
import { expensesApi } from "@/lib/api/expenses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Expense, expensePaymentMethodLabels } from "@/types/expenses"
|
||||
|
||||
import { ExpensePaymentDialog } from "@/components/accounts/ExpensePaymentDialog"
|
||||
import { ExpensePaymentStatusBadge, ExpenseStatusBadge, isExpenseOverdue } from "@/components/accounts/expense-badges"
|
||||
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, FieldLabel } from "@/components/ui/field"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
export default function ExpenseDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const router = useRouter()
|
||||
const { id } = use(params)
|
||||
const expenseId = Number(id)
|
||||
|
||||
const [expense, setExpense] = useState<Expense | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(expenseId) ? loadError : `Invalid expense id '${id}'.`
|
||||
const [busy, setBusy] = useState<"post" | "delete" | "cancel" | null>(null)
|
||||
const [payOpen, setPayOpen] = useState(false)
|
||||
const [cancelOpen, setCancelOpen] = useState(false)
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [cancelReason, setCancelReason] = useState("")
|
||||
const [cancelError, setCancelError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!Number.isFinite(expenseId)) return
|
||||
expensesApi
|
||||
.get(expenseId)
|
||||
.then((res) => {
|
||||
setExpense(res.data)
|
||||
if (consumePayFlag() && res.data.status !== "Cancelled" && res.data.balanceAmount > 0) setPayOpen(true)
|
||||
})
|
||||
.catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [expenseId])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
async function handlePost() {
|
||||
if (!expense) return
|
||||
setBusy("post")
|
||||
try {
|
||||
const posted = await expensesApi.post(expense.expenseId)
|
||||
setExpense(posted)
|
||||
toast.success("Expense posted", `Journal ${posted.glJournalNo ?? "—"}`)
|
||||
} catch (err) {
|
||||
toast.error("Could not post expense", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!expense) return
|
||||
setBusy("delete")
|
||||
try {
|
||||
await expensesApi.remove(expense.expenseId)
|
||||
toast.success("Draft deleted", expense.docNo)
|
||||
router.push("/dashboard/accounts/expenses")
|
||||
} catch (err) {
|
||||
toast.error("Could not delete expense", errorMessage(err))
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!expense) return
|
||||
if (!cancelReason.trim()) {
|
||||
setCancelError("Enter a reason")
|
||||
return
|
||||
}
|
||||
setBusy("cancel")
|
||||
try {
|
||||
const cancelled = await expensesApi.cancel(expense.expenseId, cancelReason.trim())
|
||||
setExpense(cancelled)
|
||||
setCancelOpen(false)
|
||||
toast.success(
|
||||
"Expense cancelled",
|
||||
cancelled.glReversalJournalNo ? `Reversing journal ${cancelled.glReversalJournalNo}` : cancelled.docNo
|
||||
)
|
||||
} catch (err) {
|
||||
setCancelError(errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackLink />
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!expense) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
const overdue = isExpenseOverdue(expense, localToday())
|
||||
const canPay = expense.status !== "Cancelled" && expense.balanceAmount > 0
|
||||
const canCancel = expense.status !== "Cancelled" && expense.paidAmount === 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<BackLink />
|
||||
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{expense.docNo}</h1>
|
||||
<ExpenseStatusBadge status={expense.status} />
|
||||
{expense.status === "Posted" && <ExpensePaymentStatusBadge status={expense.paymentStatus} />}
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{expense.categoryName} · {expense.titleName}
|
||||
{expense.payee ? ` — ${expense.payee}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{expense.status === "Draft" && (
|
||||
<>
|
||||
<Link href={`/dashboard/accounts/expenses/${expense.expenseId}/edit`} className={cn(buttonVariants({ variant: "outline" }))}>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Link>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(true)} disabled={busy !== null}>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handlePost} disabled={busy !== null}>
|
||||
<CheckCircle2 className="size-4" />
|
||||
{busy === "post" ? "Posting…" : "Post"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{expense.status === "Posted" && canCancel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCancelReason("")
|
||||
setCancelError(null)
|
||||
setCancelOpen(true)
|
||||
}}
|
||||
disabled={busy !== null}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
Cancel expense
|
||||
</Button>
|
||||
)}
|
||||
{canPay && (
|
||||
<Button onClick={() => setPayOpen(true)} disabled={busy !== null}>
|
||||
<CircleDollarSign className="size-4" />
|
||||
{expense.status === "Draft" ? "Post & Pay" : "Pay"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<Amount label="Amount" value={expense.amount} />
|
||||
<Amount label="Paid" value={expense.paidAmount} />
|
||||
<Amount label="Balance" value={expense.balanceAmount} tone={overdue ? "danger" : undefined} hint={overdue ? "Overdue" : undefined} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card p-4 shadow-sm sm:p-6">
|
||||
<dl className="grid grid-cols-1 gap-x-6 gap-y-4 text-sm sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Detail label="Bill date" value={formatReportDate(expense.expenseDate)} />
|
||||
<Detail label="Due date" value={expense.dueDate ? formatReportDate(expense.dueDate) : "—"} danger={overdue} />
|
||||
<Detail label="Period covered" value={expense.periodMonth ?? "—"} />
|
||||
<Detail label="Category" value={expense.categoryName} />
|
||||
<Detail label="Title" value={expense.titleName} />
|
||||
<Detail label="Payee" value={expense.payee ?? "—"} />
|
||||
<Detail label="Vendor" value={expense.vendorName ?? "—"} />
|
||||
<Detail label="Bill / account ref" value={expense.billReference ?? "—"} />
|
||||
<Detail label="Created" value={formatReportDate(expense.createdAt)} />
|
||||
{expense.status !== "Draft" && (
|
||||
<>
|
||||
<Detail label="GL expense account" value={expense.glExpenseAccountCode ?? "—"} />
|
||||
<Detail label="Accrual journal" value={expense.glJournalNo ?? "—"} />
|
||||
<Detail label="Posted" value={formatReportDate(expense.glPostedAt)} />
|
||||
</>
|
||||
)}
|
||||
{expense.status === "Cancelled" && (
|
||||
<>
|
||||
<Detail label="Reversal journal" value={expense.glReversalJournalNo ?? "— (cancelled as draft)"} />
|
||||
<Detail label="Cancel reason" value={expense.cancelReason ?? "—"} />
|
||||
</>
|
||||
)}
|
||||
{expense.description && (
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<dt className="text-muted-foreground">Description</dt>
|
||||
<dd className="mt-0.5 whitespace-pre-wrap text-foreground">{expense.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<div className="border-b px-4 py-3">
|
||||
<h2 className="text-base font-semibold text-foreground">Payments</h2>
|
||||
</div>
|
||||
{expense.payments.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">No payments recorded yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-4 text-sm">Date</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Method</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Account</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Cheque / ref</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Journal</TableHead>
|
||||
<TableHead className="h-11 px-4 text-right text-sm">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{expense.payments.map((p) => (
|
||||
<TableRow key={p.expensePaymentId}>
|
||||
<TableCell className="px-4 py-3">{formatReportDate(p.paymentDate)}</TableCell>
|
||||
<TableCell className="px-4 py-3">{expensePaymentMethodLabels[p.paymentMethod]}</TableCell>
|
||||
<TableCell className="px-4 py-3">{p.bankAccountName}</TableCell>
|
||||
<TableCell className="px-4 py-3">{[p.chequeNo, p.reference].filter(Boolean).join(" · ") || "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3">{p.glJournalNo ?? "—"}</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right font-medium tabular-nums">{formatAmount(p.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExpensePaymentDialog
|
||||
expense={expense}
|
||||
open={payOpen}
|
||||
onOpenChange={setPayOpen}
|
||||
onDone={(updated) => (updated ? setExpense(updated) : load())}
|
||||
/>
|
||||
|
||||
<Dialog open={cancelOpen} onOpenChange={setCancelOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cancel expense {expense.docNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A reversing journal entry will be posted to the ledger. This cannot be undone.
|
||||
</p>
|
||||
<Field data-invalid={!!cancelError}>
|
||||
<FieldLabel htmlFor="exp-cancel-reason" required>Reason</FieldLabel>
|
||||
<Textarea id="exp-cancel-reason" value={cancelReason} maxLength={250} rows={3} onChange={(e) => setCancelReason(e.target.value)} />
|
||||
<FieldError errors={[cancelError ? { message: cancelError } : undefined]} />
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelOpen(false)} disabled={busy === "cancel"}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleCancel} disabled={busy === "cancel"}>
|
||||
{busy === "cancel" ? "Cancelling…" : "Cancel expense"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete draft {expense.docNo}?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">The draft has not been posted, so nothing reaches the ledger. This cannot be undone.</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)} disabled={busy === "delete"}>
|
||||
Keep it
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={busy === "delete"}>
|
||||
{busy === "delete" ? "Deleting…" : "Delete draft"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** "Save & pay" on the form lands here with ?pay=1 — true once, then the flag is dropped from the URL. */
|
||||
function consumePayFlag(): boolean {
|
||||
const url = new URL(window.location.href)
|
||||
if (url.searchParams.get("pay") !== "1") return false
|
||||
url.searchParams.delete("pay")
|
||||
window.history.replaceState(null, "", url.toString())
|
||||
return true
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link href="/dashboard/accounts/expenses" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" />
|
||||
Expenses
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Amount({ label, value, tone, hint }: { label: string; value: number; tone?: "danger"; hint?: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className={cn("text-xl font-bold tabular-nums", tone === "danger" ? "text-destructive" : "text-foreground")}>{formatAmount(value)}</p>
|
||||
{hint && <p className="text-xs font-medium text-destructive">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Detail({ label, value, danger }: { label: string; value: string; danger?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd className={cn("mt-0.5 font-medium", danger ? "text-destructive" : "text-foreground")}>{value}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client"
|
||||
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
import { ExpenseForm } from "@/components/accounts/ExpenseForm"
|
||||
|
||||
export default function NewExpensePage() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">New Expense</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Record a bill — pick its category and title, then save it as a draft, post it, or post and pay it in one step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ExpenseForm
|
||||
cancelHref="/dashboard/accounts/expenses"
|
||||
onSaved={(expense, action) =>
|
||||
router.push(`/dashboard/accounts/expenses/${expense.expenseId}${action === "pay" ? "?pay=1" : ""}`)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
CalendarDays,
|
||||
CheckCircle2,
|
||||
CircleDollarSign,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Plus,
|
||||
Receipt,
|
||||
Wallet,
|
||||
} from "lucide-react"
|
||||
|
||||
import { expenseSetupApi, expensesApi } from "@/lib/api/expenses"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { downloadCsv } from "@/lib/csv-export"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { Branch } from "@/types/hrm"
|
||||
import {
|
||||
ExpenseCategory,
|
||||
ExpenseListFilterParams,
|
||||
ExpensePaymentStatus,
|
||||
ExpenseStatus,
|
||||
ExpenseSummary,
|
||||
ExpenseSummaryReport,
|
||||
} from "@/types/expenses"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { ExpensePaymentDialog } from "@/components/accounts/ExpensePaymentDialog"
|
||||
import { ExpensePaymentStatusBadge, ExpenseStatusBadge, isExpenseOverdue } from "@/components/accounts/expense-badges"
|
||||
import { localToday } from "@/components/sales/PaymentLinesEditor"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { DateRangePicker } from "@/components/ui/date-picker"
|
||||
import { Field, FieldLabel } from "@/components/ui/field"
|
||||
import { FilterBar } from "@/components/ui/filter-bar"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type StatusFilter = ExpenseStatus | "All"
|
||||
type PaymentFilter = ExpensePaymentStatus | "All" | "Overdue"
|
||||
type SortKey = "docNo" | "date" | "title" | "payee" | "due" | "amount" | "balance"
|
||||
type SortOrder = "asc" | "desc"
|
||||
type DateRange = "Month" | "Today" | "7d" | "30d" | "All" | "Custom"
|
||||
type CustomRange = { from: Date | undefined; to?: Date | undefined }
|
||||
|
||||
const statusOptions: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All statuses" },
|
||||
{ value: "Draft", label: "Draft" },
|
||||
{ value: "Posted", label: "Posted" },
|
||||
{ value: "Cancelled", label: "Cancelled" },
|
||||
]
|
||||
const paymentOptions: { value: PaymentFilter; label: string }[] = [
|
||||
{ value: "All", label: "Any payment status" },
|
||||
{ value: "Unpaid", label: "Unpaid" },
|
||||
{ value: "PartiallyPaid", label: "Partially paid" },
|
||||
{ value: "Paid", label: "Paid" },
|
||||
{ value: "Overdue", label: "Overdue" },
|
||||
]
|
||||
const dateRanges: { value: DateRange; label: string }[] = [
|
||||
{ value: "Month", label: "This month" },
|
||||
{ value: "Today", label: "Today" },
|
||||
{ value: "7d", label: "7 days" },
|
||||
{ value: "30d", label: "30 days" },
|
||||
{ value: "All", label: "All time" },
|
||||
{ value: "Custom", label: "Custom range" },
|
||||
]
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// "All time" still needs a lower bound for the period summary (the API defaults to this month).
|
||||
const ALL_TIME_FROM = "2000-01-01"
|
||||
|
||||
/** Local yyyy-MM-dd — the API's DateOnly `from`/`to` format. */
|
||||
function toIsoDate(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
/** Inclusive bounds ending today, as on the Sales list. A custom range with only a start covers that day. */
|
||||
function dateRangeBounds(range: DateRange, custom: CustomRange | undefined): { from?: string; to?: string } {
|
||||
const today = new Date()
|
||||
if (range === "Custom") {
|
||||
if (!custom?.from) return {}
|
||||
return { from: toIsoDate(custom.from), to: toIsoDate(custom.to ?? custom.from) }
|
||||
}
|
||||
if (range === "All") return {}
|
||||
if (range === "Month") return { from: toIsoDate(new Date(today.getFullYear(), today.getMonth(), 1)), to: toIsoDate(today) }
|
||||
const days = range === "Today" ? 1 : range === "7d" ? 7 : 30
|
||||
const from = new Date(today)
|
||||
from.setDate(today.getDate() - (days - 1))
|
||||
return { from: toIsoDate(from), to: toIsoDate(today) }
|
||||
}
|
||||
|
||||
/** Same clickable sort header as the Sales and Items tables. */
|
||||
function SortableHeader({ label, active, order, onClick, alignRight }: { label: string; active: boolean; order: SortOrder; onClick: () => void; alignRight?: boolean }) {
|
||||
return (
|
||||
<button type="button" className={cn("flex items-center gap-1 hover:text-foreground", alignRight && "ml-auto")} onClick={onClick}>
|
||||
{label}
|
||||
{active ? (
|
||||
order === "asc" ? <ArrowUp className="size-3.5" /> : <ArrowDown className="size-3.5" />
|
||||
) : (
|
||||
<ArrowUpDown className="size-3.5 text-muted-foreground/50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ExpensesPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, activeBranchId, branchingEnabled } = useAuth()
|
||||
const today = localToday()
|
||||
|
||||
const [rows, setRows] = useState<ExpenseSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [summary, setSummary] = useState<ExpenseSummaryReport | null>(null)
|
||||
const [categories, setCategories] = useState<ExpenseCategory[]>([])
|
||||
const [branches, setBranches] = useState<Branch[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [paymentFilter, setPaymentFilter] = useState<PaymentFilter>("All")
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null)
|
||||
const [titleId, setTitleId] = useState<number | null>(null)
|
||||
const [dateRange, setDateRange] = useState<DateRange>("Month")
|
||||
const [customRange, setCustomRange] = useState<CustomRange | undefined>(undefined)
|
||||
const { from, to } = dateRangeBounds(dateRange, customRange)
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [rowsPerPage, setRowsPerPage] = useState(PAGE_SIZE_OPTIONS[1])
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
const [postingId, setPostingId] = useState<number | null>(null)
|
||||
const [payExpense, setPayExpense] = useState<ExpenseSummary | null>(null)
|
||||
const [payOpen, setPayOpen] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [sortKey, setSortKey] = useState<SortKey | null>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>("asc")
|
||||
|
||||
useEffect(() => {
|
||||
expenseSetupApi.list(true).then(setCategories).catch(() => setCategories([]))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin && branchingEnabled) branchesHrmApi.list({ pageSize: 200, status: "Active" }).then((r) => setBranches(r.items)).catch(() => setBranches([]))
|
||||
}, [isAdmin, branchingEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
const filters: ExpenseListFilterParams = useMemo(
|
||||
() => ({
|
||||
q: query || undefined,
|
||||
status: status === "All" ? undefined : status,
|
||||
paymentStatus: paymentFilter === "All" || paymentFilter === "Overdue" ? undefined : paymentFilter,
|
||||
overdueOnly: paymentFilter === "Overdue" ? true : undefined,
|
||||
categoryId: categoryId ?? undefined,
|
||||
titleId: titleId ?? undefined,
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
[query, status, paymentFilter, categoryId, titleId, from, to]
|
||||
)
|
||||
|
||||
// Page and fetched size belong to one filter combination: any filter change (or a new rows-per-page)
|
||||
// drops back to page 1. pageSize grows on mobile "Show more", like the Sales list.
|
||||
const pagingKey = `${JSON.stringify(filters)}|${rowsPerPage}`
|
||||
const [paging, setPaging] = useState({ key: "", page: 1, pageSize: rowsPerPage })
|
||||
const { page, pageSize } = paging.key === pagingKey ? paging : { page: 1, pageSize: rowsPerPage }
|
||||
const setPage = (p: number) => setPaging({ key: pagingKey, page: p, pageSize })
|
||||
|
||||
useEffect(() => {
|
||||
expensesApi
|
||||
.list({ ...filters, page, pageSize })
|
||||
.then((res) => {
|
||||
setError(null)
|
||||
setRows(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setLoadingMore(false))
|
||||
}, [filters, page, pageSize, reloadKey, activeBranchId])
|
||||
|
||||
useEffect(() => {
|
||||
expensesApi
|
||||
.summary({ from: from ?? ALL_TIME_FROM, to: to ?? today })
|
||||
.then(setSummary)
|
||||
.catch(() => setSummary(null))
|
||||
}, [from, to, today, reloadKey, activeBranchId])
|
||||
|
||||
const titleOptions = useMemo(
|
||||
() => (categoryId ? categories.find((c) => c.expenseCategoryId === categoryId)?.titles ?? [] : categories.flatMap((c) => c.titles)),
|
||||
[categories, categoryId]
|
||||
)
|
||||
|
||||
// Admins bypass branch scoping server-side — the header's branch switcher filters client-side,
|
||||
// as on Purchase Invoices (docs/BRANCH_ISOLATION_PLAN.md §Frontend impact).
|
||||
const visibleRows = useMemo(
|
||||
() => rows?.filter((row) => !(isAdmin && branchingEnabled && activeBranchId) || row.branchId === activeBranchId) ?? [],
|
||||
[rows, isAdmin, branchingEnabled, activeBranchId]
|
||||
)
|
||||
|
||||
// Desktop sorting over the loaded page, client-side; null keeps the server's order (newest first).
|
||||
function toggleSort(key: SortKey) {
|
||||
if (key === sortKey) setSortOrder((o) => (o === "asc" ? "desc" : "asc"))
|
||||
else {
|
||||
setSortKey(key)
|
||||
setSortOrder("asc")
|
||||
}
|
||||
}
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!sortKey) return visibleRows
|
||||
const value = (r: ExpenseSummary): string | number => {
|
||||
switch (sortKey) {
|
||||
case "docNo": return r.docNo
|
||||
case "date": return r.expenseDate
|
||||
case "title": return `${r.categoryName} ${r.titleName}`
|
||||
case "payee": return r.payee ?? ""
|
||||
case "due": return r.dueDate ?? ""
|
||||
case "amount": return r.amount
|
||||
case "balance": return r.balanceAmount
|
||||
}
|
||||
}
|
||||
return [...visibleRows].sort((a, b) => {
|
||||
const va = value(a), vb = value(b)
|
||||
const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb))
|
||||
return sortOrder === "asc" ? cmp : -cmp
|
||||
})
|
||||
}, [visibleRows, sortKey, sortOrder])
|
||||
|
||||
function branchName(branchId: number) {
|
||||
return branches.find((b) => b.branchId === branchId)?.name ?? `#${branchId}`
|
||||
}
|
||||
|
||||
function openExpense(row: ExpenseSummary) {
|
||||
router.push(`/dashboard/accounts/expenses/${row.expenseId}`)
|
||||
}
|
||||
|
||||
function openPay(row: ExpenseSummary) {
|
||||
setPayExpense(row)
|
||||
setPayOpen(true)
|
||||
}
|
||||
|
||||
async function handlePost(row: ExpenseSummary) {
|
||||
setPostingId(row.expenseId)
|
||||
try {
|
||||
const posted = await expensesApi.post(row.expenseId)
|
||||
toast.success("Expense posted", `${posted.docNo} — journal ${posted.glJournalNo ?? "—"}`)
|
||||
setReloadKey((k) => k + 1)
|
||||
} catch (err) {
|
||||
toast.error("Could not post expense", errorMessage(err))
|
||||
} finally {
|
||||
setPostingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
function handleShowMore() {
|
||||
setLoadingMore(true)
|
||||
setPaging({ key: pagingKey, page: 1, pageSize: pageSize + rowsPerPage })
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setExporting(true)
|
||||
try {
|
||||
const all: ExpenseSummary[] = []
|
||||
for (let p = 1; ; p++) {
|
||||
const res = await expensesApi.list({ ...filters, page: p, pageSize: 200 })
|
||||
all.push(...res.items)
|
||||
if (p >= res.pagination.totalPages) break
|
||||
}
|
||||
downloadCsv(`expenses-${from ?? "all"}-to-${to ?? "all"}.csv`, all, [
|
||||
{ header: "Doc no", value: (r) => r.docNo },
|
||||
{ header: "Date", value: (r) => r.expenseDate },
|
||||
{ header: "Due date", value: (r) => r.dueDate },
|
||||
{ header: "Category", value: (r) => r.categoryName },
|
||||
{ header: "Title", value: (r) => r.titleName },
|
||||
{ header: "Payee", value: (r) => r.payee },
|
||||
{ header: "Bill ref", value: (r) => r.billReference },
|
||||
{ header: "Period", value: (r) => r.periodMonth },
|
||||
{ header: "Amount", value: (r) => r.amount.toFixed(2) },
|
||||
{ header: "Paid", value: (r) => r.paidAmount.toFixed(2) },
|
||||
{ header: "Balance", value: (r) => r.balanceAmount.toFixed(2) },
|
||||
{ header: "Status", value: (r) => r.status },
|
||||
{ header: "Payment status", value: (r) => (r.status === "Posted" ? r.paymentStatus : "") },
|
||||
{ header: "GL journal", value: (r) => r.glJournalNo },
|
||||
])
|
||||
} catch (err) {
|
||||
toast.error("Could not export expenses", errorMessage(err))
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const advancedActive = paymentFilter !== "All" || categoryId !== null || titleId !== null
|
||||
const hasFilters = status !== "All" || advancedActive || query.length > 0 || dateRange !== "All"
|
||||
const showBranch = isAdmin && branchingEnabled
|
||||
|
||||
/** Icon-only row actions, same as the Sales list: open, post (drafts), pay / post & pay. */
|
||||
function rowActions(row: ExpenseSummary, variant: "ghost" | "outline", size: "icon-sm" | "icon") {
|
||||
const posting = postingId === row.expenseId
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant={variant} size={size} onClick={() => openExpense(row)} aria-label={`Open ${row.docNo}`} title="Open">
|
||||
<FileText className="size-4" />
|
||||
</Button>
|
||||
{row.status === "Draft" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={posting}
|
||||
onClick={() => handlePost(row)}
|
||||
aria-label={`Post ${row.docNo}`}
|
||||
title={posting ? "Posting…" : "Post"}
|
||||
>
|
||||
{posting ? <Loader2 className="size-4 animate-spin" /> : <CheckCircle2 className="size-4" />}
|
||||
</Button>
|
||||
)}
|
||||
{row.status !== "Cancelled" && row.balanceAmount > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={() => openPay(row)}
|
||||
aria-label={`${row.status === "Draft" ? "Post and pay" : "Pay"} ${row.docNo}`}
|
||||
title={row.status === "Draft" ? "Post & Pay" : "Pay"}
|
||||
>
|
||||
<CircleDollarSign className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Expenses</h1>
|
||||
<p className="text-base text-muted-foreground">Utility bills, rent, salaries and other business expenses, with payment tracking.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Button variant="outline" size="lg" onClick={handleExport} disabled={exporting} className="w-full sm:w-auto">
|
||||
<Download className="size-5" />
|
||||
{exporting ? "Exporting…" : "Export CSV"}
|
||||
</Button>
|
||||
<Link href="/dashboard/accounts/expenses/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Expense
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amounts are passed as strings so StatCard shows full 2dp values rather than its compact "1.2K" form. */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{summary ? (
|
||||
<>
|
||||
<StatCard label="Posted in period" value={formatAmount(summary.totalAmount)} icon={Receipt} />
|
||||
<StatCard label="Unpaid in period" value={formatAmount(summary.unpaidAmount)} icon={Wallet} />
|
||||
<StatCard label="Outstanding (all time)" value={formatAmount(summary.outstandingAmount)} icon={CircleDollarSign} />
|
||||
<StatCard label={`Overdue (${summary.overdueCount})`} value={formatAmount(summary.overdueAmount)} icon={AlertTriangle} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-22.5 rounded-xl" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border bg-card shadow-sm">
|
||||
<FilterBar
|
||||
className="border-b px-4 py-4"
|
||||
searchFirst
|
||||
search={searchInput}
|
||||
onSearchChange={setSearchInput}
|
||||
searchPlaceholder="Filter by doc no., payee or bill ref"
|
||||
advancedActive={advancedActive}
|
||||
onClearAdvanced={() => {
|
||||
setPaymentFilter("All")
|
||||
setCategoryId(null)
|
||||
setTitleId(null)
|
||||
}}
|
||||
advanced={
|
||||
<>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="exp-filter-payment">Payment status</FieldLabel>
|
||||
<Select<PaymentFilter> value={paymentFilter} onValueChange={(v) => setPaymentFilter(v ?? "All")}>
|
||||
<SelectTrigger id="exp-filter-payment"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="exp-filter-category">Category</FieldLabel>
|
||||
<Select<number | null>
|
||||
value={categoryId}
|
||||
onValueChange={(v) => {
|
||||
setCategoryId(v)
|
||||
setTitleId(null)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="exp-filter-category"><SelectValue placeholder="All categories" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>All categories</SelectItem>
|
||||
{categories.map((c) => <SelectItem key={c.expenseCategoryId} value={c.expenseCategoryId}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="exp-filter-title">Title</FieldLabel>
|
||||
<Select<number | null> value={titleId} onValueChange={(v) => setTitleId(v)}>
|
||||
<SelectTrigger id="exp-filter-title"><SelectValue placeholder="All titles" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null}>All titles</SelectItem>
|
||||
{titleOptions.map((t) => <SelectItem key={t.expenseTitleId} value={t.expenseTitleId}>{t.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-10! w-36 text-sm" aria-label="Status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select<DateRange> value={dateRange} onValueChange={(v) => setDateRange(v ?? "Month")}>
|
||||
<SelectTrigger className="h-10! w-40 text-sm" aria-label="Date range">
|
||||
<CalendarDays className="size-4 text-muted-foreground" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{dateRanges.map((r) => <SelectItem key={r.value} value={r.value}>{r.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{dateRange === "Custom" && (
|
||||
<DateRangePicker value={customRange} onChange={setCustomRange} placeholder="Pick from – to dates" className="sm:w-72" />
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
|
||||
{error && <div className="mx-4 mt-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
|
||||
{!error && rows === null && (
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-14 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visibleRows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
|
||||
<Receipt className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">{hasFilters ? "No expenses match your filters." : "No expenses yet."}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && visibleRows.length > 0 && (
|
||||
<>
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 px-4 py-4 md:hidden">
|
||||
{visibleRows.map((row) => (
|
||||
<div
|
||||
key={row.expenseId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
openExpense(row)
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") openExpense(row)
|
||||
}}
|
||||
className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-semibold text-foreground">{row.docNo}</p>
|
||||
<p className="truncate text-sm text-muted-foreground">
|
||||
{row.categoryName} · {row.titleName}
|
||||
</p>
|
||||
{row.payee && <p className="truncate text-sm text-muted-foreground">{row.payee}</p>}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||
<ExpenseStatusBadge status={row.status} />
|
||||
{row.status === "Posted" && <ExpensePaymentStatusBadge status={row.paymentStatus} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Date / Due</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{formatReportDate(row.expenseDate)} /{" "}
|
||||
<span className={cn(isExpenseOverdue(row, today) && "text-destructive")}>
|
||||
{row.dueDate ? formatReportDate(row.dueDate) : "—"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Amount</p>
|
||||
<p className="font-semibold text-foreground tabular-nums">{formatAmount(row.amount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Paid</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{formatAmount(row.paidAmount)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Balance</p>
|
||||
<p className="font-medium text-foreground tabular-nums">{formatAmount(row.balanceAmount)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t pt-3" data-no-row-nav>
|
||||
{rowActions(row, "outline", "icon")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pagination && rows.length < pagination.totalItems && (
|
||||
<Button type="button" variant="outline" size="lg" className="w-full" onClick={handleShowMore} disabled={loadingMore}>
|
||||
{loadingMore ? "Loading…" : "Show more"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop: table. */}
|
||||
<div className="mx-4 my-4 hidden overflow-x-auto rounded-xl border md:block">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Expense" active={sortKey === "docNo"} order={sortOrder} onClick={() => toggleSort("docNo")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Category / Title" active={sortKey === "title"} order={sortOrder} onClick={() => toggleSort("title")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Payee" active={sortKey === "payee"} order={sortOrder} onClick={() => toggleSort("payee")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Date" active={sortKey === "date"} order={sortOrder} onClick={() => toggleSort("date")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm"><SortableHeader label="Due" active={sortKey === "due"} order={sortOrder} onClick={() => toggleSort("due")} /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Amount" active={sortKey === "amount"} order={sortOrder} onClick={() => toggleSort("amount")} alignRight /></TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm"><SortableHeader label="Balance" active={sortKey === "balance"} order={sortOrder} onClick={() => toggleSort("balance")} alignRight /></TableHead>
|
||||
{showBranch && <TableHead className="h-12 px-3 text-sm">Branch</TableHead>}
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedRows.map((row) => (
|
||||
<TableRow
|
||||
key={row.expenseId}
|
||||
className="cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return
|
||||
openExpense(row)
|
||||
}}
|
||||
>
|
||||
<TableCell className="px-3 py-3.5 font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Receipt className="size-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<Link href={`/dashboard/accounts/expenses/${row.expenseId}`} className="hover:underline" data-no-row-nav>
|
||||
{row.docNo}
|
||||
</Link>
|
||||
{row.billReference && <p className="text-xs font-normal text-muted-foreground">Ref {row.billReference}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<p>{row.titleName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{row.categoryName}
|
||||
{row.periodMonth ? ` · ${row.periodMonth}` : ""}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{row.payee ?? "—"}</TableCell>
|
||||
<TableCell className="px-3 py-3.5">{formatReportDate(row.expenseDate)}</TableCell>
|
||||
<TableCell className={cn("px-3 py-3.5", isExpenseOverdue(row, today) && "font-semibold text-destructive")}>
|
||||
{row.dueDate ? formatReportDate(row.dueDate) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right font-semibold tabular-nums">{formatAmount(row.amount)}</TableCell>
|
||||
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(row.balanceAmount)}</TableCell>
|
||||
{showBranch && (
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<Badge variant="outline" className="h-5 w-fit justify-center border-transparent bg-primary/10 px-2 text-xs text-primary">
|
||||
{branchName(row.branchId)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-3 py-3.5">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<ExpenseStatusBadge status={row.status} />
|
||||
{row.status === "Posted" && <ExpensePaymentStatusBadge status={row.paymentStatus} />}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-3 py-3.5" data-no-row-nav>
|
||||
<div className="flex items-center gap-1">{rowActions(row, "ghost", "icon-sm")}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && (
|
||||
<div className="hidden px-4 pb-4 md:block">
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} pageSizeOptions={PAGE_SIZE_OPTIONS} onPageSizeChange={setRowsPerPage} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExpensePaymentDialog expense={payExpense} open={payOpen} onOpenChange={setPayOpen} onDone={() => setReloadKey((k) => k + 1)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link"
|
||||
import { BookText, Inbox, Wallet, type LucideIcon } from "lucide-react"
|
||||
import { BookText, Inbox, PiggyBank, Receipt, Tags, Wallet, type LucideIcon } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
||||
@@ -22,6 +22,24 @@ const areas: { title: string; description: string; href: string; icon: LucideIco
|
||||
href: "/dashboard/accounts/received-cheques",
|
||||
icon: Inbox,
|
||||
},
|
||||
{
|
||||
title: "Expenses",
|
||||
description: "Utility bills, rent, salaries and other expenses — post to the ledger and track what is paid.",
|
||||
href: "/dashboard/accounts/expenses",
|
||||
icon: Receipt,
|
||||
},
|
||||
{
|
||||
title: "Expense Setup",
|
||||
description: "Expense categories and titles, and the GL account each title posts to.",
|
||||
href: "/dashboard/accounts/expense-setup",
|
||||
icon: Tags,
|
||||
},
|
||||
{
|
||||
title: "Bank Deposits",
|
||||
description: "Till cash taken to the bank after the day ends — see the cash in hand, deposit it, and track what is left.",
|
||||
href: "/dashboard/accounts/bank-deposits",
|
||||
icon: PiggyBank,
|
||||
},
|
||||
]
|
||||
|
||||
export default function AccountsHubPage() {
|
||||
@@ -30,7 +48,7 @@ export default function AccountsHubPage() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Accounts</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Cash/Bank accounts and cheque management, from the General Ledger service.
|
||||
Cash/Bank accounts, cheque management and business expenses, posted to the General Ledger service.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ClipboardCheck, PackageMinus, Tag } from "lucide-react"
|
||||
|
||||
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PendingApproval, PendingApprovals } from "@/types/approvals"
|
||||
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
function hrefFor(p: PendingApproval) {
|
||||
return p.docType === "ADJ" ? `/dashboard/stock/adjustments/${p.docId}` : `/dashboard/products/price-adjustments/${p.docId}`
|
||||
}
|
||||
|
||||
/** How long a request has waited — "3 h", "2 days". */
|
||||
function age(iso: string) {
|
||||
const hours = Math.floor((Date.now() - new Date(iso).getTime()) / 3_600_000)
|
||||
if (hours < 1) return "just now"
|
||||
if (hours < 24) return `${hours} h`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} day${days === 1 ? "" : "s"}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The Approvals inbox (docs/DUAL_APPROVAL_PLAN.md §10): pending stock and price adjustments the
|
||||
* current user can approve — actionable first, oldest first — plus their own requests waiting on others.
|
||||
*/
|
||||
export default function ApprovalsInboxPage() {
|
||||
const [data, setData] = useState<PendingApprovals | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null)
|
||||
const [rejecting, setRejecting] = useState<PendingApproval | null>(null)
|
||||
const [reason, setReason] = useState("")
|
||||
const [reasonError, setReasonError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
approvalsApi.pending().then((d) => { setData(d); setError(null) }).catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
async function approve(p: PendingApproval) {
|
||||
setBusyKey(`${p.docType}-${p.docId}`)
|
||||
try {
|
||||
if (p.docType === "ADJ") {
|
||||
const next = await stockAdjustmentsApi.approve(p.docId)
|
||||
toast.success(next.status === "Posted" ? "Approved and posted" : "Approved", next.status === "Posted" ? `${next.docNo} — stock updated.` : `${next.docNo} now needs a second approval.`)
|
||||
} else {
|
||||
const next = await priceAdjustmentsApi.approve(p.docId)
|
||||
toast.success(next.status === "Approved" ? "Approved — prices applied" : "Approved", next.status === "Approved" ? `${next.docNo} is in effect.` : `${next.docNo} now needs a second approval.`)
|
||||
}
|
||||
load()
|
||||
} catch (err) {
|
||||
toast.error(`Could not approve ${p.docNo}`, errorMessage(err))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!rejecting) return
|
||||
if (!reason.trim()) {
|
||||
setReasonError("Give a reason")
|
||||
return
|
||||
}
|
||||
setBusyKey(`${rejecting.docType}-${rejecting.docId}`)
|
||||
try {
|
||||
if (rejecting.docType === "ADJ") await stockAdjustmentsApi.reject(rejecting.docId, reason.trim())
|
||||
else await priceAdjustmentsApi.reject(rejecting.docId, reason.trim())
|
||||
toast.success("Rejected", rejecting.docNo)
|
||||
setRejecting(null)
|
||||
setReason("")
|
||||
load()
|
||||
} catch (err) {
|
||||
setReasonError(errorMessage(err))
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const actionable = data?.items.filter((i) => i.canApprove) ?? []
|
||||
const waiting = data?.items.filter((i) => !i.canApprove) ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Approvals</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Stock and price adjustments waiting for approval. Nothing takes effect until it is fully approved — by people other than the requester, and two different people when two approvals are needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && data === null && <Skeleton className="h-64 w-full" />}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<Section title={`Waiting for you (${actionable.length})`} empty="Nothing needs your approval right now.">
|
||||
{actionable.map((p) => (
|
||||
<Row key={`${p.docType}-${p.docId}`} item={p}>
|
||||
<Button variant="outline" size="sm" onClick={() => { setRejecting(p); setReason(""); setReasonError(null) }} disabled={busyKey !== null}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => approve(p)} disabled={busyKey !== null}>
|
||||
{busyKey === `${p.docType}-${p.docId}` ? "Approving…" : p.level >= p.requiredApprovals ? "Approve & apply" : "Approve"}
|
||||
</Button>
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{waiting.length > 0 && (
|
||||
<Section title={`Waiting for others (${waiting.length})`} empty="">
|
||||
{waiting.map((p) => (
|
||||
<Row key={`${p.docType}-${p.docId}`} item={p} note={p.whyNot} />
|
||||
))}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={rejecting !== null} onOpenChange={(open) => !open && setRejecting(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reject {rejecting?.docNo}</DialogTitle>
|
||||
<DialogDescription>Nothing will be applied. The requester is told your reason.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field data-invalid={!!reasonError}>
|
||||
<FieldLabel htmlFor="inbox-reject-reason" required>Reason</FieldLabel>
|
||||
<Textarea id="inbox-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={() => setRejecting(null)} disabled={busyKey !== null}>Keep it</Button>
|
||||
<Button variant="destructive" onClick={reject} disabled={busyKey !== null}>Reject</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, empty, children }: { title: string; empty: string; children: React.ReactNode }) {
|
||||
const hasItems = Array.isArray(children) ? children.length > 0 : !!children
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
{hasItems ? (
|
||||
<div className="flex flex-col gap-2">{children}</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-12 text-center">
|
||||
<ClipboardCheck className="size-10 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{empty}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ item, note, children }: { item: PendingApproval; note?: string | null; children?: React.ReactNode }) {
|
||||
const Icon = item.docType === "ADJ" ? PackageMinus : Tag
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="size-4.5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href={hrefFor(item)} className="font-semibold text-foreground hover:underline">{item.docNo}</Link>
|
||||
<Badge variant="outline" className={cn("border-transparent text-xs", item.level >= 2 ? "bg-primary/10 text-primary" : "bg-warning/10 text-warning")}>
|
||||
{item.requiredApprovals === 2 ? `Approval ${item.level} of 2` : "Needs 1 approval"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="truncate text-sm text-muted-foreground">{item.summary}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Requested by {item.requestedByName ?? `#${item.requestedBy}`} · waiting {age(item.requestedAt)}
|
||||
{note ? ` · ${note}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Link href={hrefFor(item)} className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>Open</Link>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { Eye, Pencil, Plus, Search, Trash2, Users } from "lucide-react"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
import { branchesHrmApi } from "@/lib/api/hrm-masters"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { generateCustomerCode } from "@/lib/customer-code"
|
||||
import { EntityStatus, PaginationMeta } from "@/types/common"
|
||||
@@ -43,6 +44,9 @@ const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
/** Unpaid balance across the customer's Posted invoices (0 against an older backend). */
|
||||
const outstanding = (c: Customer) => c.outstandingAmount ?? 0
|
||||
|
||||
export default function CustomersPage() {
|
||||
const router = useRouter()
|
||||
const { isAdmin, branchId: myBranchId, activeBranchId, branchingEnabled } = useAuth()
|
||||
@@ -432,10 +436,24 @@ export default function CustomersPage() {
|
||||
<p className="text-muted-foreground">Phone</p>
|
||||
<p className="font-medium text-foreground">{c.phone ?? "—"}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Credit limit</p>
|
||||
<p className="font-medium text-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Last paid</p>
|
||||
<p className="font-medium text-foreground">{c.lastPaymentDate ? formatReportDate(c.lastPaymentDate) : "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Remaining payment</p>
|
||||
<p className={cn("font-medium tabular-nums", outstanding(c) > 0 ? "text-foreground" : "text-muted-foreground")}>
|
||||
{outstanding(c) > 0 ? `LKR ${formatAmount(outstanding(c))}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Unpaid invoices</p>
|
||||
<p className="font-medium text-foreground">{c.outstandingInvoiceCount ?? 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-1 border-t pt-3" data-no-row-nav>
|
||||
@@ -493,6 +511,9 @@ export default function CustomersPage() {
|
||||
<TableHead className="w-28">Type</TableHead>
|
||||
<TableHead className="w-36">Phone</TableHead>
|
||||
<TableHead className="w-36">Credit limit</TableHead>
|
||||
<TableHead className="w-32">Last paid</TableHead>
|
||||
<TableHead className="w-36 text-right">Remaining payment</TableHead>
|
||||
<TableHead className="w-28 text-right">Unpaid invoices</TableHead>
|
||||
<TableHead className="w-28">Status</TableHead>
|
||||
<TableHead className="w-24 text-center">Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -515,6 +536,19 @@ export default function CustomersPage() {
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{c.phone ?? "—"}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">LKR {c.creditLimit.toLocaleString(undefined, { maximumFractionDigits: 2 })}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{c.lastPaymentDate ? formatReportDate(c.lastPaymentDate) : "—"}</TableCell>
|
||||
<TableCell className={cn("text-right text-sm tabular-nums", outstanding(c) > 0 ? "font-semibold text-foreground" : "text-muted-foreground")}>
|
||||
{outstanding(c) > 0 ? `LKR ${formatAmount(outstanding(c))}` : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm tabular-nums">
|
||||
{(c.outstandingInvoiceCount ?? 0) > 0 ? (
|
||||
<Badge variant="outline" className="h-5 border-transparent bg-warning/10 px-2 text-xs text-warning">
|
||||
{c.outstandingInvoiceCount}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useParams } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft, BadgeDollarSign, Boxes, Package, PackageCheck, Pencil, Save, ShoppingCart, TrendingUp } from "lucide-react"
|
||||
|
||||
import { approvalsApi, priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { categoriesApi } from "@/lib/api/categories"
|
||||
import { productConfig } from "@/lib/api/product-config"
|
||||
@@ -15,8 +16,9 @@ import { stockApi } from "@/lib/api/stock"
|
||||
import { errorMessage, fieldErrors } from "@/lib/error-map"
|
||||
import { validateItemForm } from "@/lib/validations/master-data"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
import { Item, ItemReorderSetting, MeasureUnit, StockNature, TaxRate, TrackingMode, Warranty, WarrantyPeriodMonths } from "@/types/master-data"
|
||||
import { LedgerEntry } from "@/types/stock"
|
||||
import { ItemPriceHistory } from "@/types/approvals"
|
||||
|
||||
/** Entry units. Only ml/g are stored — the server normalises L and Kg ×1000 on write. */
|
||||
const CONTENT_UNITS: MeasureUnit[] = ["Ml", "L", "G", "Kg"]
|
||||
@@ -37,6 +39,28 @@ import { toast } from "@/components/ui/toast"
|
||||
import LineChart from "@/components/ui/line-chart"
|
||||
import BarChart from "@/components/ui/bar-chart"
|
||||
import { ItemImagesPanel } from "@/components/products/ItemImagesPanel"
|
||||
import { ItemReorderPanel } from "@/components/products/ItemReorderPanel"
|
||||
|
||||
/**
|
||||
* The Edit dialog's simple low-stock alert, read back from the item's reorder rules: one level and
|
||||
* reorder qty, watching one warehouse (a single rule) or every warehouse (several rules).
|
||||
* `uniform` is false when the rules differ per warehouse — those are edited in Reorder settings.
|
||||
*/
|
||||
function alertFromRules(rules: ItemReorderSetting[]) {
|
||||
if (rules.length === 0) return { lowStockAt: "", reorderQty: "", warehouseId: null as number | null, uniform: true }
|
||||
const first = rules[0]
|
||||
const uniform = rules.every((r) => r.reorderPoint === first.reorderPoint && r.reorderQty === first.reorderQty)
|
||||
return {
|
||||
lowStockAt: String(first.reorderPoint),
|
||||
reorderQty: String(first.reorderQty),
|
||||
warehouseId: rules.length === 1 ? first.warehouseId : null,
|
||||
uniform,
|
||||
}
|
||||
}
|
||||
|
||||
function fmtPrice(v: number | null) {
|
||||
return v === null ? "—" : `LKR ${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function docLink(sourceDocType: string, sourceDocId: number): string | null {
|
||||
if (sourceDocType === "GRN") return `/dashboard/receiving/grn/${sourceDocId}`
|
||||
@@ -107,9 +131,14 @@ export default function ItemDetailPage() {
|
||||
// Raw string: an empty box means "no content size", which is not the same as 0.
|
||||
const [contentQty, setContentQty] = useState("")
|
||||
const [contentUnit, setContentUnit] = useState<MeasureUnit | null>(null)
|
||||
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
|
||||
// isn't sent on save — nothing to wire it to server-side.
|
||||
// Low-stock alert (saved as reorder rules via PUT /items/{id}/reorder, not on the item itself):
|
||||
// alert level + reorder qty, watching `warehouseId` or, when null, every warehouse.
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
const [lowStockAt, setLowStockAt] = useState("")
|
||||
const [reorderQty, setReorderQty] = useState("")
|
||||
// Selling prices change only through an approved price adjustment while approval is on (docs/DUAL_APPROVAL_PLAN.md §6.3).
|
||||
const [pricesLocked, setPricesLocked] = useState(true)
|
||||
const [priceHistory, setPriceHistory] = useState<ItemPriceHistory[] | null>(null)
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [conflict, setConflict] = useState(false)
|
||||
@@ -141,6 +170,10 @@ export default function ItemDetailPage() {
|
||||
setSellingFlatPrice(data.sellingFlatPrice === null ? "" : String(data.sellingFlatPrice))
|
||||
setContentQty(data.contentQty === null ? "" : String(data.contentQty))
|
||||
setContentUnit(data.contentUnit)
|
||||
const alert = alertFromRules(data.reorder ?? [])
|
||||
setLowStockAt(alert.lowStockAt)
|
||||
setReorderQty(alert.reorderQty)
|
||||
setWarehouseId(alert.warehouseId)
|
||||
}
|
||||
|
||||
function load() {
|
||||
@@ -167,6 +200,9 @@ export default function ItemDetailPage() {
|
||||
})
|
||||
.catch(() => {})
|
||||
productConfig().then((cfg) => setImagesEnabled(cfg.itemImagesEnabled)).catch(() => {})
|
||||
// Locked unless Settings → Approvals turned price approval off; the server enforces it either way.
|
||||
approvalsApi.getConfig().then((c) => setPricesLocked(c.priceAdjustmentLevels > 0)).catch(() => {})
|
||||
priceAdjustmentsApi.itemHistory(itemId).then(setPriceHistory).catch(() => setPriceHistory([]))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [itemId])
|
||||
|
||||
@@ -265,10 +301,23 @@ export default function ItemDetailPage() {
|
||||
async function handleSave() {
|
||||
if (!item || !etag) return
|
||||
setSaveError(null)
|
||||
const nextErrors = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit })
|
||||
const nextErrors: Record<string, string> = validateItemForm({ sku, name, categoryId, baseUomId, contentQty, contentUnit })
|
||||
if (lowStockAt.trim()) {
|
||||
const point = Number(lowStockAt)
|
||||
const qty = Number(reorderQty)
|
||||
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
|
||||
else if (stockNature !== "Stocked") nextErrors.lowStockAt = "Only stocked items hold stock — clear the low stock alert or make it Stocked"
|
||||
if (!reorderQty.trim() || Number.isNaN(qty) || qty <= 0) nextErrors.reorderQty = "Enter how many to reorder (more than 0)"
|
||||
}
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
// Only rewrite the reorder rules when the alert fields were actually changed, so per-warehouse
|
||||
// rules set under Reorder settings survive an unrelated edit (name, price, …).
|
||||
const saved = alertFromRules(item.reorder ?? [])
|
||||
const alertChanged =
|
||||
lowStockAt.trim() !== saved.lowStockAt || (lowStockAt.trim() !== "" && (reorderQty.trim() !== saved.reorderQty || warehouseId !== saved.warehouseId))
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = await itemsApi.update(
|
||||
@@ -285,7 +334,21 @@ export default function ItemDetailPage() {
|
||||
},
|
||||
etag
|
||||
)
|
||||
applyItem(result.data)
|
||||
let saved = result.data
|
||||
if (alertChanged) {
|
||||
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId)
|
||||
const settings = lowStockAt.trim()
|
||||
? watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
|
||||
: []
|
||||
try {
|
||||
const res = await itemsApi.updateReorder(item.itemId, { settings })
|
||||
saved = { ...saved, reorder: res.settings }
|
||||
} catch (err) {
|
||||
// The item itself is saved; say the alert wasn't, rather than failing the whole edit.
|
||||
toast.warning("Item saved, but the low stock alert was not", errorMessage(err))
|
||||
}
|
||||
}
|
||||
applyItem(saved)
|
||||
setEtag(result.etag)
|
||||
setEditOpen(false)
|
||||
setImagesVersion((v) => v + 1)
|
||||
@@ -324,6 +387,12 @@ export default function ItemDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Rules the simple alert fields can't show faithfully: levels that differ per warehouse, or
|
||||
// several warehouses watched but not every one.
|
||||
const savedRules = item?.reorder ?? []
|
||||
const alertIsCustom =
|
||||
!alertFromRules(savedRules).uniform || (savedRules.length > 1 && warehouses.length > 0 && savedRules.length < warehouses.length)
|
||||
|
||||
function uomName(uomId: number) {
|
||||
return uoms.find((u) => u.uomId === uomId)?.name ?? `#${uomId}`
|
||||
}
|
||||
@@ -419,6 +488,66 @@ export default function ItemDetailPage() {
|
||||
dialog closes so this copy picks up what changed there. */}
|
||||
<ItemImagesPanel key={imagesVersion} itemId={item.itemId} />
|
||||
|
||||
{/* Per-warehouse low-stock levels. Re-keyed on the saved rules so it picks up a change
|
||||
made through the Edit dialog's simple alert fields. */}
|
||||
<ItemReorderPanel
|
||||
key={JSON.stringify(item.reorder ?? [])}
|
||||
itemId={item.itemId}
|
||||
stockNature={item.stockNature}
|
||||
settings={item.reorder ?? []}
|
||||
warehouses={warehouses}
|
||||
uomName={uomName(item.baseUomId)}
|
||||
onSaved={(settings) => applyItem({ ...item, reorder: settings })}
|
||||
/>
|
||||
|
||||
{/* Every applied selling-price change and who approved it (docs/DUAL_APPROVAL_PLAN.md §6). */}
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-base font-semibold text-foreground">Price history</h2>
|
||||
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="text-sm font-medium text-primary hover:underline">
|
||||
Request price change
|
||||
</Link>
|
||||
</div>
|
||||
{priceHistory === null ? (
|
||||
<Skeleton className="h-20 w-full" />
|
||||
) : priceHistory.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">No approved price changes yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-9 px-2 text-xs">Applied</TableHead>
|
||||
<TableHead className="h-9 px-2 text-xs">Flat price</TableHead>
|
||||
<TableHead className="h-9 px-2 text-xs">Min / max</TableHead>
|
||||
<TableHead className="h-9 px-2 text-xs">Request</TableHead>
|
||||
<TableHead className="h-9 px-2 text-xs">Approved by</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{priceHistory.map((h) => (
|
||||
<TableRow key={h.itemPriceHistoryId}>
|
||||
<TableCell className="px-2 py-2 text-muted-foreground">{new Date(h.appliedAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell className="px-2 py-2 tabular-nums">{fmtPrice(h.oldFlatPrice)} → <span className="font-semibold">{fmtPrice(h.newFlatPrice)}</span></TableCell>
|
||||
<TableCell className="px-2 py-2 tabular-nums text-muted-foreground">
|
||||
{fmtPrice(h.newMinPrice)} / {fmtPrice(h.newMaxPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-2">
|
||||
{h.priceAdjustmentId ? (
|
||||
<Link href={`/dashboard/products/price-adjustments/${h.priceAdjustmentId}`} className="text-primary hover:underline">{h.docNo ?? `#${h.priceAdjustmentId}`}</Link>
|
||||
) : "—"}
|
||||
</TableCell>
|
||||
<TableCell className="px-2 py-2 text-muted-foreground">
|
||||
{[h.approvedLevel1ByName, h.approvedLevel2ByName].filter(Boolean).join(", ") || "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-3 rounded-xl border p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -660,7 +789,7 @@ export default function ItemDetailPage() {
|
||||
onChange={(e) => setSellingMinPrice(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-12 text-base"
|
||||
disabled={conflict}
|
||||
disabled={conflict || pricesLocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -674,7 +803,7 @@ export default function ItemDetailPage() {
|
||||
onChange={(e) => setSellingMaxPrice(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-12 text-base"
|
||||
disabled={conflict}
|
||||
disabled={conflict || pricesLocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -688,8 +817,16 @@ export default function ItemDetailPage() {
|
||||
onChange={(e) => setSellingFlatPrice(e.target.value)}
|
||||
placeholder="Optional"
|
||||
className="h-12 text-base"
|
||||
disabled={conflict}
|
||||
disabled={conflict || pricesLocked}
|
||||
/>
|
||||
{pricesLocked && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Selling prices need approval.{" "}
|
||||
<Link href={`/dashboard/products/price-adjustments/new?itemId=${item.itemId}`} className="font-medium text-primary hover:underline">
|
||||
Request a price change
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Tax rate</Label>
|
||||
@@ -818,6 +955,50 @@ export default function ItemDetailPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Low stock alert (optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={lowStockAt}
|
||||
onChange={(e) => setLowStockAt(e.target.value)}
|
||||
placeholder="Alert when stock ≤"
|
||||
aria-label="Alert when stock is at or below"
|
||||
aria-invalid={!!errors.lowStockAt}
|
||||
className="h-12! text-base"
|
||||
disabled={conflict}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={reorderQty}
|
||||
onChange={(e) => setReorderQty(e.target.value)}
|
||||
placeholder="Reorder qty"
|
||||
aria-label="Reorder quantity"
|
||||
aria-invalid={!!errors.reorderQty}
|
||||
className="h-12! text-base"
|
||||
disabled={conflict}
|
||||
/>
|
||||
</div>
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
|
||||
errors.reorderQty ? { message: errors.reorderQty } : undefined,
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{alertIsCustom
|
||||
? "This item has its own per-warehouse levels (see Reorder settings). Changing these fields replaces them all with one level."
|
||||
: lowStockAt.trim()
|
||||
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent.`
|
||||
: "When stock drops to this count or less, you get a low-stock alert. Clear it to stop alerts."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null>
|
||||
@@ -827,9 +1008,10 @@ export default function ItemDetailPage() {
|
||||
items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))}
|
||||
>
|
||||
<SelectTrigger className="h-12! w-full text-base">
|
||||
<SelectValue placeholder="Select warehouse" />
|
||||
<SelectValue placeholder="Every warehouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={null} className="text-base">Every warehouse</SelectItem>
|
||||
{warehouses.map((w) => (
|
||||
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">
|
||||
{w.code} — {w.name}
|
||||
@@ -837,6 +1019,7 @@ export default function ItemDetailPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock.</p>
|
||||
</div>
|
||||
</div>
|
||||
<ItemImagesPanel itemId={item.itemId} editable canUpload={imagesEnabled} />
|
||||
|
||||
@@ -132,10 +132,14 @@ export default function NewItemPage() {
|
||||
const [subCategories, setSubCategories] = useState<SubCategory[]>([])
|
||||
const [subCategoryId, setSubCategoryId] = useState<number | null>(null)
|
||||
const [brandId, setBrandId] = useState<number | null>(null)
|
||||
// Frontend-only: there's no warehouse field anywhere on the Item contract, so this
|
||||
// isn't sent on submit — nothing to wire it to server-side.
|
||||
// Items carry no warehouse; this picks which warehouse the low-stock alert below watches
|
||||
// (none picked = every warehouse). Saved as reorder rules (PUT /items/{id}/reorder) after create.
|
||||
const [warehouses, setWarehouses] = useState<{ warehouseId: number; code: string; name: string }[]>([])
|
||||
const [warehouseId, setWarehouseId] = useState<number | null>(null)
|
||||
// Low-stock alert (reorder point): when available stock falls to or below this, the item shows
|
||||
// on Stock → Reorder Alerts and a "Low stock" notification is sent. Blank = no alert.
|
||||
const [lowStockAt, setLowStockAt] = useState("")
|
||||
const [reorderQty, setReorderQty] = useState("")
|
||||
|
||||
const [checkedItemTypeIds, setCheckedItemTypeIds] = useState<number[]>([])
|
||||
const [valuesByCategory, setValuesByCategory] = useState<Record<number, DimensionValue[]>>({})
|
||||
@@ -562,6 +566,13 @@ export default function NewItemPage() {
|
||||
if (config?.warrantyPeriodsEnabled && warranty === "Warranty" && warrantyPeriodMonths === null) {
|
||||
nextErrors.warrantyPeriodMonths = "Select a warranty period"
|
||||
}
|
||||
if (lowStockAt.trim()) {
|
||||
const point = Number(lowStockAt)
|
||||
const qty = Number(reorderQty)
|
||||
if (Number.isNaN(point) || point < 0) nextErrors.lowStockAt = "Low stock alert must be 0 or more"
|
||||
else if (stockNature !== "Stocked") nextErrors.lowStockAt = "Only stocked items hold stock — clear the low stock alert or make it Stocked"
|
||||
if (!reorderQty.trim() || Number.isNaN(qty) || qty <= 0) nextErrors.reorderQty = "Enter how many to reorder (more than 0)"
|
||||
}
|
||||
// Should never fire — addValue is the real gate — so it catches stale state only.
|
||||
const contentSweep = validateVariantContent(
|
||||
variants.map((v) => v.key),
|
||||
@@ -663,6 +674,21 @@ export default function NewItemPage() {
|
||||
if (failed > 0)
|
||||
toast.warning("Some images did not upload", `${failed} upload${failed === 1 ? "" : "s"} failed — add them from the item page.`)
|
||||
}
|
||||
|
||||
// Low-stock alert: one reorder rule per watched warehouse, per created item. Best-effort like
|
||||
// images — the items exist either way, and the rules can be fixed under Reorder settings.
|
||||
const watched = warehouseId !== null ? [warehouseId] : warehouses.map((w) => w.warehouseId)
|
||||
if (lowStockAt.trim() && watched.length > 0) {
|
||||
const settings = watched.map((id) => ({ warehouseId: id, reorderPoint: Number(lowStockAt), reorderQty: Number(reorderQty) }))
|
||||
let failed = 0
|
||||
for (const itemId of createdIds) {
|
||||
await itemsApi.updateReorder(itemId, { settings }).catch(() => {
|
||||
failed += 1
|
||||
})
|
||||
}
|
||||
if (failed > 0)
|
||||
toast.warning("Low stock alert not saved for every item", `${failed} item${failed === 1 ? "" : "s"} failed — set it under the item's Reorder settings.`)
|
||||
}
|
||||
router.push("/dashboard/products")
|
||||
} catch (err) {
|
||||
// Each row is its own POST with no transaction, so a failure partway (e.g. a
|
||||
@@ -794,6 +820,52 @@ export default function NewItemPage() {
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Low stock alert (optional)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={lowStockAt}
|
||||
onChange={(e) => {
|
||||
setLowStockAt(e.target.value)
|
||||
clearErrors("lowStockAt", "reorderQty")
|
||||
}}
|
||||
placeholder="Alert when stock ≤"
|
||||
aria-label="Alert when stock is at or below"
|
||||
aria-invalid={!!errors.lowStockAt}
|
||||
className="h-12! text-base"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
inputMode="decimal"
|
||||
value={reorderQty}
|
||||
onChange={(e) => {
|
||||
setReorderQty(e.target.value)
|
||||
clearErrors("reorderQty")
|
||||
}}
|
||||
placeholder="Reorder qty"
|
||||
aria-label="Reorder quantity"
|
||||
aria-invalid={!!errors.reorderQty}
|
||||
className="h-12! text-base"
|
||||
/>
|
||||
</div>
|
||||
<FieldError
|
||||
errors={[
|
||||
errors.lowStockAt ? { message: errors.lowStockAt } : undefined,
|
||||
errors.reorderQty ? { message: errors.reorderQty } : undefined,
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{lowStockAt.trim()
|
||||
? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.`
|
||||
: "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base">Warehouse (optional)</Label>
|
||||
<Select<number | null>
|
||||
@@ -812,6 +884,7 @@ export default function NewItemPage() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">Where the low-stock alert watches stock. None picked = every warehouse.</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-base" required>Base UOM</Label>
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"use client"
|
||||
|
||||
import { use, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { AlertTriangle, ArrowLeft } from "lucide-react"
|
||||
|
||||
import { priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PriceAdjustment, PriceAdjustmentLine } from "@/types/approvals"
|
||||
|
||||
import { ApprovalPanel, PriceAdjustmentStatusBadge } from "@/components/approvals/ApprovalPanel"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const price = (v: number | null) => (v === null ? "—" : formatAmount(v))
|
||||
|
||||
/** True while pending and the item has been repriced since the request — approving would be refused. */
|
||||
function isStale(l: PriceAdjustmentLine) {
|
||||
return l.currentMinPrice !== l.oldMinPrice || l.currentMaxPrice !== l.oldMaxPrice || l.currentFlatPrice !== l.oldFlatPrice
|
||||
}
|
||||
|
||||
/** One price change request: old → new per item, the approval trail, and the actions (docs/DUAL_APPROVAL_PLAN.md §6). */
|
||||
export default function PriceAdjustmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const priceAdjustmentId = Number(id)
|
||||
const [adjustment, setAdjustment] = useState<PriceAdjustment | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(priceAdjustmentId) ? loadError : `Invalid request id '${id}'.`
|
||||
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(priceAdjustmentId)) return
|
||||
priceAdjustmentsApi.get(priceAdjustmentId).then(setAdjustment).catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [priceAdjustmentId])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackLink />
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!adjustment) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
const p = adjustment
|
||||
const pending = p.pendingLevel !== null
|
||||
const outcome = p.status === "Approved" ? "applied" : p.status === "Rejected" ? "rejected" : p.status === "Cancelled" ? "withdrawn" : "pending"
|
||||
const staleLines = pending ? p.lines.filter(isStale) : []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<BackLink />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{p.docNo}</h1>
|
||||
<PriceAdjustmentStatusBadge status={p.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">{p.reason}</p>
|
||||
{pending && <p className="text-sm text-warning">Prices have not changed yet — they change when this request is fully approved.</p>}
|
||||
</div>
|
||||
|
||||
{staleLines.length > 0 && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
<span>
|
||||
{staleLines.map((l) => l.sku).join(", ")} {staleLines.length === 1 ? "was" : "were"} repriced after this request was made, so it can no longer be approved.
|
||||
Reject it and raise a new request against the current prices.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_24rem]">
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="px-3">Item</TableHead>
|
||||
<TableHead className="px-3 text-right">Min</TableHead>
|
||||
<TableHead className="px-3 text-right">Max</TableHead>
|
||||
<TableHead className="px-3 text-right">Flat</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{p.lines.map((l) => (
|
||||
<TableRow key={l.priceAdjustmentLineId} className={cn(pending && isStale(l) && "bg-warning/5")}>
|
||||
<TableCell className="px-3 py-2">
|
||||
<Link href={`/dashboard/products/${l.itemId}`} className="font-medium hover:underline">{l.sku}</Link>
|
||||
<p className="text-xs text-muted-foreground">{l.itemName}</p>
|
||||
</TableCell>
|
||||
<Change from={l.oldMinPrice} to={l.newMinPrice} />
|
||||
<Change from={l.oldMaxPrice} to={l.newMaxPrice} />
|
||||
<Change from={l.oldFlatPrice} to={l.newFlatPrice} />
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ApprovalPanel
|
||||
what="price adjustment"
|
||||
requiredApprovals={p.requiredApprovals}
|
||||
pendingLevel={p.pendingLevel}
|
||||
history={p.approvalHistory}
|
||||
requestedByLabel={p.createdByName ?? `user #${p.createdBy}`}
|
||||
requestedAt={p.createdAt}
|
||||
canApprove={p.canApprove}
|
||||
whyNot={p.whyNot}
|
||||
canWithdraw={p.canWithdraw}
|
||||
finalEffect="The new prices take effect on the items straight away."
|
||||
outcome={outcome}
|
||||
outcomeAt={p.appliedAt}
|
||||
rejectReason={p.rejectReason}
|
||||
onApprove={async (comment) => {
|
||||
try {
|
||||
const next = await priceAdjustmentsApi.approve(p.priceAdjustmentId, comment)
|
||||
setAdjustment(next)
|
||||
toast.success(next.status === "Approved" ? "Approved — prices applied" : "Approved", next.status === "Approved" ? `${next.docNo} is in effect.` : `${next.docNo} now needs a second approval.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not approve", errorMessage(err))
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
onReject={async (reason) => {
|
||||
try {
|
||||
setAdjustment(await priceAdjustmentsApi.reject(p.priceAdjustmentId, reason))
|
||||
toast.success("Request rejected", p.docNo)
|
||||
} catch (err) {
|
||||
toast.error("Could not reject", errorMessage(err))
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
onWithdraw={async () => {
|
||||
try {
|
||||
setAdjustment(await priceAdjustmentsApi.cancel(p.priceAdjustmentId))
|
||||
toast.success("Request withdrawn", p.docNo)
|
||||
} catch (err) {
|
||||
toast.error("Could not withdraw", errorMessage(err))
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Change({ from, to }: { from: number | null; to: number | null }) {
|
||||
const changed = from !== to
|
||||
return (
|
||||
<TableCell className="px-3 py-2 text-right tabular-nums">
|
||||
{changed ? (
|
||||
<>
|
||||
<span className="text-muted-foreground line-through">{price(from)}</span>
|
||||
<span className="ml-2 font-semibold text-foreground">{price(to)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{price(from)}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link href="/dashboard/products/price-adjustments" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" />
|
||||
Price Adjustments
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
"use client"
|
||||
|
||||
import { Suspense, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { ArrowLeft, ArrowRight, Send } from "lucide-react"
|
||||
|
||||
import { priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Item, ItemPurchasePrice } from "@/types/master-data"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Combobox, ComboboxOption } from "@/components/ui/combobox"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
type PriceKey = "min" | "max" | "flat"
|
||||
const PRICE_FIELDS: { key: PriceKey; label: string; hint: string }[] = [
|
||||
{ key: "flat", label: "Selling price (flat)", hint: "The price sales use by default" },
|
||||
{ key: "min", label: "Minimum price", hint: "Lowest a cashier may sell at" },
|
||||
{ key: "max", label: "Maximum price", hint: "Highest a cashier may sell at" },
|
||||
]
|
||||
|
||||
const asInput = (v: number | null) => (v === null ? "" : String(v))
|
||||
const asPrice = (v: string) => (v.trim() === "" ? null : Number(v))
|
||||
const current = (item: Item, key: PriceKey) =>
|
||||
key === "min" ? item.sellingMinPrice : key === "max" ? item.sellingMaxPrice : item.sellingFlatPrice
|
||||
|
||||
export default function NewPriceAdjustmentPage() {
|
||||
// useSearchParams needs a Suspense boundary, or the production build can't prerender the route.
|
||||
return (
|
||||
<Suspense>
|
||||
<NewPriceAdjustmentForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a selling-price change (docs/DUAL_APPROVAL_PLAN.md §6), step by step:
|
||||
* 1. select the item, 2. see its current prices, 3. enter the new prices, 4. give a reason, 5. submit for approval.
|
||||
* Prices don't change until the request is approved. Opening with ?itemId=… starts on that item.
|
||||
*/
|
||||
function NewPriceAdjustmentForm() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const [item, setItem] = useState<Item | null>(null)
|
||||
const [loadingItem, setLoadingItem] = useState(false)
|
||||
const [options, setOptions] = useState<ComboboxOption<number>[] | null>(null)
|
||||
const [prices, setPrices] = useState<Record<PriceKey, string>>({ min: "", max: "", flat: "" })
|
||||
const [reason, setReason] = useState("")
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// The item's latest GRN purchase prices, to price against (null while loading).
|
||||
const [purchases, setPurchases] = useState<ItemPurchasePrice[] | null>(null)
|
||||
|
||||
function choose(data: Item) {
|
||||
setItem(data)
|
||||
setPrices({ min: asInput(data.sellingMinPrice), max: asInput(data.sellingMaxPrice), flat: asInput(data.sellingFlatPrice) })
|
||||
setErrors({})
|
||||
setPurchases(null)
|
||||
itemsApi.purchasePrices(data.itemId).then(setPurchases).catch(() => setPurchases([]))
|
||||
}
|
||||
|
||||
const lastCost = purchases && purchases.length > 0 ? purchases[0].netUnitCost : null
|
||||
|
||||
function selectItem(itemId: number | null) {
|
||||
if (itemId === null) {
|
||||
setItem(null)
|
||||
return
|
||||
}
|
||||
if (itemId === item?.itemId) return
|
||||
setLoadingItem(true)
|
||||
itemsApi
|
||||
.get(itemId)
|
||||
.then(({ data }) => choose(data))
|
||||
.catch((err) => toast.error("Could not load the item", errorMessage(err)))
|
||||
.finally(() => setLoadingItem(false))
|
||||
}
|
||||
|
||||
// Came from an item's "Request price change": start on that item.
|
||||
const preselect = Number(searchParams.get("itemId"))
|
||||
useEffect(() => {
|
||||
if (!Number.isFinite(preselect) || preselect <= 0) return
|
||||
itemsApi
|
||||
.get(preselect)
|
||||
.then(({ data }) => choose(data))
|
||||
.catch((err) => toast.error("Could not load the item", errorMessage(err)))
|
||||
}, [preselect])
|
||||
|
||||
// Every active item for the dropdown (the list endpoint caps a page at 200, so page through).
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const all: ComboboxOption<number>[] = []
|
||||
for (let page = 1; page <= 25; page++) {
|
||||
const res = await itemsApi.list({ status: "Active", page, pageSize: 200 })
|
||||
all.push(...res.items.map((i) => ({ value: i.itemId, label: `${i.sku} — ${i.name}` })))
|
||||
if (page >= res.pagination.totalPages) break
|
||||
}
|
||||
if (!cancelled) setOptions(all.sort((a, b) => a.label.localeCompare(b.label)))
|
||||
})().catch((err) => {
|
||||
if (!cancelled) {
|
||||
setOptions([])
|
||||
toast.error("Could not load items", errorMessage(err))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
function validate(): Record<string, string> {
|
||||
const next: Record<string, string> = {}
|
||||
if (!item) {
|
||||
next.item = "Select an item first"
|
||||
return next
|
||||
}
|
||||
const [min, max, flat] = [asPrice(prices.min), asPrice(prices.max), asPrice(prices.flat)]
|
||||
if ([min, max, flat].some((v) => v !== null && (Number.isNaN(v) || v < 0))) next.prices = "Prices must be 0 or more"
|
||||
else if (min !== null && max !== null && min > max) next.prices = "The minimum price is above the maximum"
|
||||
else if (flat !== null && ((min !== null && flat < min) || (max !== null && flat > max))) next.prices = "The selling price must be between the minimum and maximum"
|
||||
else if (min === item.sellingMinPrice && max === item.sellingMaxPrice && flat === item.sellingFlatPrice) next.prices = "Enter at least one new price"
|
||||
if (!reason.trim()) next.reason = "Say why the price is changing"
|
||||
return next
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const next = validate()
|
||||
setErrors(next)
|
||||
if (Object.keys(next).length > 0 || !item) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const created = await priceAdjustmentsApi.create({
|
||||
reason: reason.trim(),
|
||||
lines: [{ itemId: item.itemId, newMinPrice: asPrice(prices.min), newMaxPrice: asPrice(prices.max), newFlatPrice: asPrice(prices.flat) }],
|
||||
})
|
||||
toast.success(
|
||||
created.status === "Approved" ? "Price updated" : "Sent for approval",
|
||||
created.status === "Approved"
|
||||
? `${created.docNo} applied.`
|
||||
: `${created.docNo} — the new price takes effect once ${created.requiredApprovals === 2 ? "two people approve it" : "it is approved"}.`
|
||||
)
|
||||
router.push(`/dashboard/products/price-adjustments/${created.priceAdjustmentId}`)
|
||||
} catch (err) {
|
||||
toast.error("Could not send the price change", errorMessage(err))
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Link href="/dashboard/products/price-adjustments" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" />
|
||||
Price Adjustments
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Request price change</h1>
|
||||
<p className="text-base text-muted-foreground">The item keeps its current price until this request is approved.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-3xl flex-col gap-5">
|
||||
{/* 1. Item */}
|
||||
<Step number={1} title="Select the item" done={!!item}>
|
||||
{options === null ? (
|
||||
<Skeleton className="h-12 w-full" />
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Combobox<number>
|
||||
items={
|
||||
// Keep the chosen item listed even if it isn't active (came in via ?itemId=…).
|
||||
item && !options.some((o) => o.value === item.itemId)
|
||||
? [{ value: item.itemId, label: `${item.sku} — ${item.name}` }, ...options]
|
||||
: options
|
||||
}
|
||||
value={item?.itemId ?? null}
|
||||
onValueChange={selectItem}
|
||||
placeholder="Select an item — type to search by SKU or name"
|
||||
emptyText="No matching active items."
|
||||
className="h-12 text-base"
|
||||
aria-invalid={!!errors.item}
|
||||
/>
|
||||
{loadingItem && <p className="text-sm text-muted-foreground">Loading the item’s prices…</p>}
|
||||
<FieldError errors={[errors.item ? { message: errors.item } : undefined]} />
|
||||
</div>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
{/* 2. Current price */}
|
||||
<Step number={2} title="Current price" muted={!item}>
|
||||
{item ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{PRICE_FIELDS.map((f) => (
|
||||
<div key={f.key} className="rounded-lg border p-3">
|
||||
<p className="text-xs text-muted-foreground">{f.label}</p>
|
||||
<p className="text-lg font-bold tabular-nums text-foreground">{priceText(current(item, f.key))}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3">
|
||||
<p className="text-xs text-muted-foreground">Last GRN cost</p>
|
||||
{purchases === null ? (
|
||||
<Skeleton className="mt-1 h-6 w-24" />
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-bold tabular-nums text-foreground">{priceText(lastCost)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{lastCost === null ? "Never received" : <Margin price={item.sellingFlatPrice} cost={lastCost} label="Margin now" />}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What it was bought at recently — the cost side of the new price. */}
|
||||
{purchases !== null && purchases.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/40 text-xs text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">GRN</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Received</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Vendor</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Qty</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Unit cost</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Disc.</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Net cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{purchases.map((p, i) => (
|
||||
<tr key={`${p.grnId}-${i}`} className="border-t">
|
||||
<td className="px-3 py-2">
|
||||
<Link href={`/dashboard/receiving/grn/${p.grnId}`} className="text-primary hover:underline">{p.grnDocNo}</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">{new Date(p.receivedAt).toLocaleDateString()}</td>
|
||||
<td className="px-3 py-2">{p.vendorName ?? `#${p.vendorId}`}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{p.qty}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(p.unitCost)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums text-muted-foreground">{p.discountPct ? `${p.discountPct}%` : "—"}</td>
|
||||
<td className="px-3 py-2 text-right font-semibold tabular-nums">{formatAmount(p.netUnitCost)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="border-t px-3 py-2 text-xs text-muted-foreground">
|
||||
Net cost = unit cost after discount, excluding VAT (the stock cost). Latest {purchases.length} receipt{purchases.length === 1 ? "" : "s"}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Shown once you select an item.</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
{/* 3. New price */}
|
||||
<Step number={3} title="New price" muted={!item}>
|
||||
{item ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{PRICE_FIELDS.map((f) => {
|
||||
const now = current(item, f.key)
|
||||
const next = asPrice(prices[f.key])
|
||||
const changed = next !== now && !(next !== null && Number.isNaN(next))
|
||||
return (
|
||||
<Field key={f.key}>
|
||||
<FieldLabel htmlFor={`new-${f.key}`}>{f.label}</FieldLabel>
|
||||
<Input
|
||||
id={`new-${f.key}`}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={prices[f.key]}
|
||||
onChange={(e) => { setPrices((p) => ({ ...p, [f.key]: e.target.value })); setErrors((x) => ({ ...x, prices: "" })) }}
|
||||
placeholder="No price"
|
||||
aria-invalid={!!errors.prices}
|
||||
className={cn("h-11 text-base tabular-nums", changed && "border-primary")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{changed ? <ChangeText from={now} to={next} /> : f.hint}
|
||||
</p>
|
||||
</Field>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<FieldError errors={[errors.prices ? { message: errors.prices } : undefined]} />
|
||||
{lastCost !== null && asPrice(prices.flat) !== null && !Number.isNaN(asPrice(prices.flat)) && (
|
||||
<p className="text-sm">
|
||||
<Margin price={asPrice(prices.flat)} cost={lastCost} label="Margin at the new selling price" />
|
||||
{asPrice(prices.flat)! < lastCost && <span className="ml-2 font-medium text-destructive">— below the last GRN cost</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Select an item first.</p>
|
||||
)}
|
||||
</Step>
|
||||
|
||||
{/* 4. Reason */}
|
||||
<Step number={4} title="Reason" muted={!item}>
|
||||
<Field data-invalid={!!errors.reason}>
|
||||
<FieldLabel htmlFor="pa-reason" className="sr-only">Reason</FieldLabel>
|
||||
<Textarea
|
||||
id="pa-reason"
|
||||
rows={3}
|
||||
maxLength={250}
|
||||
value={reason}
|
||||
disabled={!item}
|
||||
onChange={(e) => { setReason(e.target.value); setErrors((x) => ({ ...x, reason: "" })) }}
|
||||
placeholder="e.g. Supplier price increase from 1 November"
|
||||
/>
|
||||
<FieldError errors={[errors.reason ? { message: errors.reason } : undefined]} />
|
||||
</Field>
|
||||
</Step>
|
||||
|
||||
{/* 5. Send for approval */}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">Approvers are notified. The price changes only after approval.</p>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/dashboard/products/price-adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Cancel</Link>
|
||||
<Button size="lg" onClick={submit} disabled={!item || saving}>
|
||||
<Send className="size-4" />
|
||||
{saving ? "Sending…" : "Send for approval"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function priceText(v: number | null) {
|
||||
return v === null ? "—" : `LKR ${formatAmount(v)}`
|
||||
}
|
||||
|
||||
/** "Margin now: LKR 30.00 (25.0%)" — selling price over the last GRN net cost, red when negative. */
|
||||
function Margin({ price, cost, label }: { price: number | null; cost: number; label: string }) {
|
||||
if (price === null) return <span className="text-muted-foreground">{label}: no selling price</span>
|
||||
const margin = price - cost
|
||||
const pct = price === 0 ? null : (margin / price) * 100
|
||||
return (
|
||||
<span className={cn(margin < 0 ? "text-destructive" : "text-success")}>
|
||||
{label}: LKR {formatAmount(margin)}
|
||||
{pct !== null && ` (${pct.toFixed(1)}%)`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** "100.00 → 120.00 (+20%)", or how a price is being set / cleared. */
|
||||
function ChangeText({ from, to }: { from: number | null; to: number | null }) {
|
||||
if (from === null) return <span className="text-primary">New price: {priceText(to)}</span>
|
||||
if (to === null) return <span className="text-warning">Price will be cleared</span>
|
||||
const pct = from === 0 ? null : ((to - from) / from) * 100
|
||||
return (
|
||||
<span className={cn(to > from ? "text-success" : "text-destructive")}>
|
||||
{formatAmount(from)} <ArrowRight className="inline size-3" /> {formatAmount(to)}
|
||||
{pct !== null && ` (${pct > 0 ? "+" : ""}${pct.toFixed(1)}%)`}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Step({ number, title, done, muted, children }: { number: number; title: string; done?: boolean; muted?: boolean; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className={cn("flex flex-col gap-3 rounded-2xl border bg-card p-4 shadow-sm sm:p-5", muted && "opacity-60")}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full text-sm font-semibold",
|
||||
done ? "bg-success/15 text-success" : "bg-primary/10 text-primary"
|
||||
)}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus, Search, Tag } from "lucide-react"
|
||||
|
||||
import { priceAdjustmentsApi } from "@/lib/api/approvals"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PriceAdjustmentStatus, PriceAdjustmentSummary } from "@/types/approvals"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
|
||||
import { PriceAdjustmentStatusBadge } from "@/components/approvals/ApprovalPanel"
|
||||
import { ApprovalQueueButton } from "@/components/approvals/ApprovalQueueButton"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
|
||||
type StatusFilter = PriceAdjustmentStatus | "All"
|
||||
const statusOptions: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All statuses" },
|
||||
{ value: "PendingApproval", label: "Pending 1st approval" },
|
||||
{ value: "PendingSecondApproval", label: "Pending 2nd approval" },
|
||||
{ value: "Approved", label: "Applied" },
|
||||
{ value: "Rejected", label: "Rejected" },
|
||||
{ value: "Cancelled", label: "Withdrawn" },
|
||||
]
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50]
|
||||
|
||||
/** Selling-price change requests (docs/DUAL_APPROVAL_PLAN.md §6). Prices change only once a request is fully approved. */
|
||||
export default function PriceAdjustmentsPage() {
|
||||
const router = useRouter()
|
||||
const [rows, setRows] = useState<PriceAdjustmentSummary[] | null>(null)
|
||||
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchInput, setSearchInput] = useState("")
|
||||
const [query, setQuery] = useState("")
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE_OPTIONS[0])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => {
|
||||
priceAdjustmentsApi
|
||||
.list({ page, pageSize, q: query || undefined, status: status === "All" ? undefined : status })
|
||||
.then((res) => {
|
||||
setRows(res.items)
|
||||
setPagination(res.pagination)
|
||||
setError(null)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
}, [page, pageSize, query, status])
|
||||
|
||||
const open = (id: number) => router.push(`/dashboard/products/price-adjustments/${id}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Price Adjustments</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Requests to change selling prices. A new price takes effect only after it is approved — twice, by two different people, when dual approval is on.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<ApprovalQueueButton docType="PADJ" />
|
||||
<Link href="/dashboard/products/price-adjustments/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
Request price change
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(e) => { setSearchInput(e.target.value); setPage(1) }}
|
||||
placeholder="Search doc no. or reason…"
|
||||
className="h-12 w-full pl-11 text-base"
|
||||
aria-label="Search price adjustments"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => { setStatus(v ?? "All"); setPage(1) }}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-56" aria-label="Status"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-base">{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
|
||||
{!error && rows === null && <Skeleton className="h-64 w-full" />}
|
||||
{!error && rows !== null && rows.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
|
||||
<Tag className="size-12 text-muted-foreground" />
|
||||
<p className="text-base text-muted-foreground">No price adjustments{status !== "All" || query ? " match your filters" : " yet"}.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && rows !== null && rows.length > 0 && (
|
||||
<>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-12 px-3 text-sm">Request</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Reason</TableHead>
|
||||
<TableHead className="h-12 px-3 text-right text-sm">Items</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Requested by</TableHead>
|
||||
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((r) => (
|
||||
<TableRow key={r.priceAdjustmentId} className="cursor-pointer" onClick={() => open(r.priceAdjustmentId)}>
|
||||
<TableCell className="px-3 py-3 font-semibold">
|
||||
<Link href={`/dashboard/products/price-adjustments/${r.priceAdjustmentId}`} className="hover:underline">{r.docNo}</Link>
|
||||
<p className="text-xs font-normal text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</p>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-72 truncate px-3 py-3 text-sm text-muted-foreground">{r.reason}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-right tabular-nums">{r.lineCount}</TableCell>
|
||||
<TableCell className="px-3 py-3 text-sm">{r.createdByName ?? `#${r.createdBy}`}</TableCell>
|
||||
<TableCell className="px-3 py-3"><PriceAdjustmentStatusBadge status={r.status} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{pagination && (
|
||||
<PaginationFooter pagination={pagination} onPageChange={setPage} pageSizeOptions={PAGE_SIZE_OPTIONS} onPageSizeChange={(s) => { setPage(1); setPageSize(s) }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Banknote,
|
||||
CreditCard,
|
||||
FileDown,
|
||||
Hash,
|
||||
Landmark,
|
||||
PiggyBank,
|
||||
Receipt,
|
||||
ReceiptText,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Wallet,
|
||||
} from "lucide-react"
|
||||
|
||||
import { dayEndReportApi } from "@/lib/api/day-end-report"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
||||
import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { maskAccountNumber } from "@/types/bank-deposits"
|
||||
import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatCard } from "@/components/ui/stat-card"
|
||||
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
const MAX_RANGE_DAYS = 92
|
||||
|
||||
/** Local yyyy-MM-dd, `offsetDays` from today. */
|
||||
function localIso(offsetDays = 0): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + offsetDays)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function daysBetween(from: string, to: string): number {
|
||||
return Math.round((Date.parse(to) - Date.parse(from)) / 86_400_000) + 1
|
||||
}
|
||||
|
||||
type Loaded<T> = { key: string; data: T | null; error: string | null }
|
||||
|
||||
/** Income with a minus sign when negative, plus a red "Loss" tag so it reads as a loss. */
|
||||
function IncomeCell({ value, bold = false }: { value: number; bold?: boolean }) {
|
||||
return (
|
||||
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold ? "font-semibold" : "font-medium", value < 0 && "text-destructive")}>
|
||||
{value < 0 && <span className="mr-1.5 text-xs font-medium">Loss</span>}
|
||||
{formatSignedAmount(value)}
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
/** "Profit 1,200.00" / "Loss 300.00" (red) — a loss is labelled, not just shown negative. */
|
||||
function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) {
|
||||
const p = profitOrLoss(value)
|
||||
return (
|
||||
<TableCell className={cn("text-right tabular-nums whitespace-nowrap", bold && "font-semibold", p.isLoss && "text-destructive")}>
|
||||
<span className="mr-1.5 text-xs font-medium">{p.label}</span>
|
||||
{p.amount}
|
||||
</TableCell>
|
||||
)
|
||||
}
|
||||
|
||||
/** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */
|
||||
function ProfitStatement({ day }: { day: DayEndReportDay }) {
|
||||
const gross = profitOrLoss(day.grossProfit)
|
||||
const net = profitOrLoss(day.netProfit)
|
||||
const Icon = net.isLoss ? TrendingDown : TrendingUp
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Profit / loss</h2>
|
||||
<div className="ml-auto grid w-full max-w-md grid-cols-[1fr_auto] gap-x-6 gap-y-1.5 rounded-xl border border-border p-4 text-sm">
|
||||
<span className="text-muted-foreground">Total sales</span>
|
||||
<span className="text-right tabular-nums">{formatAmount(day.totalSales)}</span>
|
||||
<span className="text-muted-foreground">Less: cost of goods sold</span>
|
||||
<span className="text-right tabular-nums">({formatAmount(day.costOfGoodsSold)})</span>
|
||||
<span className="border-t border-border pt-1.5 font-medium">Gross {gross.label.toLowerCase()}</span>
|
||||
<span className={cn("border-t border-border pt-1.5 text-right font-medium tabular-nums", gross.isLoss && "text-destructive")}>
|
||||
{gross.amount}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Less: expenses</span>
|
||||
<span className="text-right tabular-nums">({formatAmount(day.expenseTotal)})</span>
|
||||
<div
|
||||
className={cn(
|
||||
"col-span-2 mt-1.5 flex items-center justify-between rounded-lg px-3 py-2.5",
|
||||
net.isLoss ? "bg-destructive/10 text-destructive" : "bg-success/10 text-success"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-base font-semibold">
|
||||
<Icon className="size-4" aria-hidden />
|
||||
Net {net.label.toLowerCase()}
|
||||
</span>
|
||||
<span className="text-xl font-bold tabular-nums">{net.amount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Till cash for the day (docs/BANK_DEPOSIT_MODULE_PLAN.md §9): what was carried in, what the day added, what was
|
||||
* banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module.
|
||||
*/
|
||||
function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) {
|
||||
const remaining = day.closingCashInHand ?? 0
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold text-foreground">Cash in hand</h2>
|
||||
<Link href="/dashboard/accounts/bank-deposits/new" className="text-sm font-medium text-primary hover:underline">
|
||||
Record a bank deposit
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Opening cash in hand" value={formatAmount(day.openingCashInHand ?? 0)} icon={Wallet} />
|
||||
<StatCard label="Net cash today" value={formatAmount(day.netCash)} icon={Banknote} />
|
||||
<StatCard label="Deposited to bank" value={formatAmount(day.cashDeposited ?? 0)} icon={Landmark} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
|
||||
remaining > 0 ? "bg-warning/10 ring-warning/30" : "bg-card ring-border"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-foreground">Remaining cash in hand</p>
|
||||
<PiggyBank className={cn("size-4", remaining > 0 ? "text-warning" : "text-muted-foreground")} aria-hidden />
|
||||
</div>
|
||||
<p className={cn("text-xl font-bold tracking-tight tabular-nums", remaining > 0 ? "text-warning" : "text-foreground")}>
|
||||
{formatAmount(remaining)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{remaining > 0 ? "Not yet deposited" : "All cash banked"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go
|
||||
straight to the bank.
|
||||
</p>
|
||||
|
||||
{deposits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No bank deposits for {formatReportDate(date)}.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Deposit</TableHead>
|
||||
<TableHead>Bank account</TableHead>
|
||||
<TableHead>Slip no.</TableHead>
|
||||
<TableHead>Deposited on</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{deposits.map((d) => (
|
||||
<TableRow key={d.bankDepositId}>
|
||||
<TableCell>
|
||||
<Link href={`/dashboard/accounts/bank-deposits/${d.bankDepositId}`} className="text-primary hover:underline">{d.docNo}</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{d.bankAccountName}
|
||||
{(d.bankName || d.accountNumber) && (
|
||||
<span className="text-muted-foreground"> · {[d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · ")}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{d.depositSlipNo ?? "—"}</TableCell>
|
||||
<TableCell>{formatReportDate(d.depositDate)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.amount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="font-semibold">Total deposited</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(day.cashDeposited ?? 0)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DayEndReportPage() {
|
||||
const { isAdmin, activeBranchId, activeBranchName, branchName, branchingEnabled } = useAuth()
|
||||
// Admins follow the header's branch switcher (null = all branches); everyone else is scoped to
|
||||
// their own branch server-side, whatever is sent.
|
||||
const branchId = isAdmin ? activeBranchId : null
|
||||
const branchLabel = !branchingEnabled ? "All" : isAdmin ? (activeBranchName ?? "All branches") : (branchName ?? "-")
|
||||
|
||||
const [date, setDate] = useState(localIso())
|
||||
const [from, setFrom] = useState(localIso(-29))
|
||||
const [to, setTo] = useState(localIso())
|
||||
const [downloading, setDownloading] = useState<"day" | "range" | null>(null)
|
||||
|
||||
const rangeDays = from && to ? daysBetween(from, to) : 0
|
||||
const rangeError = !from || !to
|
||||
? "Pick both dates"
|
||||
: rangeDays < 1
|
||||
? "'To' must be on or after 'From'"
|
||||
: rangeDays > MAX_RANGE_DAYS
|
||||
? `Pick at most ${MAX_RANGE_DAYS} days`
|
||||
: null
|
||||
|
||||
// Results are tagged with the request they answer; a stale or pending one reads as "loading".
|
||||
const dayKey = `${date}|${branchId}`
|
||||
const [dayResult, setDayResult] = useState<Loaded<DayEndReport> | null>(null)
|
||||
const day = dayResult?.key === dayKey ? dayResult : null
|
||||
|
||||
const rangeKey = `${from}|${to}|${branchId}`
|
||||
const [rangeResult, setRangeResult] = useState<Loaded<DayEndDailyReport> | null>(null)
|
||||
const range = rangeResult?.key === rangeKey ? rangeResult : null
|
||||
|
||||
useEffect(() => {
|
||||
if (!date) return
|
||||
let cancelled = false
|
||||
dayEndReportApi
|
||||
.getDay({ date, branchId })
|
||||
.then((data) => !cancelled && setDayResult({ key: dayKey, data, error: null }))
|
||||
.catch((err) => !cancelled && setDayResult({ key: dayKey, data: null, error: errorMessage(err) }))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [date, branchId, dayKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (rangeError) return
|
||||
let cancelled = false
|
||||
dayEndReportApi
|
||||
.getDaily({ from, to, branchId })
|
||||
.then((data) => !cancelled && setRangeResult({ key: rangeKey, data, error: null }))
|
||||
.catch((err) => !cancelled && setRangeResult({ key: rangeKey, data: null, error: errorMessage(err) }))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [from, to, branchId, rangeKey, rangeError])
|
||||
|
||||
async function downloadDay() {
|
||||
if (!day?.data) return
|
||||
setDownloading("day")
|
||||
try {
|
||||
await downloadDayEndReportPdf(day.data, branchLabel)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setDownloading(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadRange() {
|
||||
if (!range?.data) return
|
||||
setDownloading("range")
|
||||
try {
|
||||
await downloadDailyIncomePdf(range.data, branchLabel)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setDownloading(null)
|
||||
}
|
||||
}
|
||||
|
||||
const s = day?.data?.summary
|
||||
// `?? []` keeps the page up against a backend that predates these lists.
|
||||
const sales = day?.data?.sales ?? []
|
||||
const expenses = day?.data?.expenses ?? []
|
||||
const deposits = day?.data?.deposits ?? []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/dashboard/sales" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Day-End Report</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
A day's sales, how they were paid, its expenses and the resulting income — plus income day by day.
|
||||
{branchingEnabled && <> Branch: <span className="font-medium text-foreground">{branchLabel}</span>.</>}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- One day ------------------------------------------------------------------------- */}
|
||||
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="der-date" className="text-base">Business date</Label>
|
||||
<Input id="der-date" type="date" value={date} max={localIso()} onChange={(e) => setDate(e.target.value)} className="h-11 w-48 text-base" />
|
||||
</div>
|
||||
<Button variant="outline" onClick={downloadDay} disabled={!day?.data || downloading !== null}>
|
||||
<FileDown className="size-4" />
|
||||
{downloading === "day" ? "Creating PDF…" : "Download day PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{day?.error && <p className="text-sm text-destructive">{day.error}</p>}
|
||||
|
||||
{!s && !day?.error && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-[84px] rounded-xl" />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{s && (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Sales</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<StatCard label="Total sales" value={formatAmount(s.totalSales)} icon={ReceiptText} />
|
||||
<StatCard label="Paid amount" value={formatAmount(s.paidAmount)} icon={Wallet} />
|
||||
<StatCard label="Due amount" value={formatAmount(s.dueAmount)} icon={Receipt} />
|
||||
<StatCard label="Paid by cash" value={formatAmount(s.paidByCash)} icon={Banknote} />
|
||||
<StatCard label="Paid by card" value={formatAmount(s.paidByCard)} icon={CreditCard} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
|
||||
<StatCard label="Sales count" value={String(s.salesCount)} icon={Hash} />
|
||||
<StatCard label="Cash sales" value={String(s.cashSalesCount)} icon={Banknote} />
|
||||
<StatCard label="Card sales" value={String(s.cardSalesCount)} icon={CreditCard} />
|
||||
<StatCard label="Expenses" value={formatAmount(s.expenseTotal)} icon={Receipt} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 rounded-xl p-3.5 shadow-sm ring-1",
|
||||
s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-foreground">Income</p>
|
||||
{s.income >= 0
|
||||
? <TrendingUp className="size-4 text-success" aria-hidden />
|
||||
: <TrendingDown className="size-4 text-destructive" aria-hidden />}
|
||||
</div>
|
||||
<p className={cn("text-xl font-bold tracking-tight tabular-nums", s.income >= 0 ? "text-success" : "text-destructive")}>
|
||||
{formatSignedAmount(s.income)}
|
||||
</p>
|
||||
{s.income < 0 ? (
|
||||
<p className="text-xs font-semibold text-destructive">Loss — expenses are more than sales</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">Sales − expenses</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque
|
||||
({formatAmount(s.paidByOther)}) · {s.unpaidSalesCount} not paid yet · Collected on earlier invoices:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">{formatAmount(s.earlierInvoiceCollections)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CashInHandSection day={s} deposits={deposits} date={date} />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Sales of the day ({sales.length})</h2>
|
||||
{sales.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No posted sales on {formatReportDate(date)}.</p>
|
||||
) : (
|
||||
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-card">
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>No.</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead>Paid by</TableHead>
|
||||
<TableHead className="text-right">Total</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
<TableHead className="text-right">Due</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sales.map((row) => (
|
||||
<TableRow key={`${row.docType}-${row.docId}`}>
|
||||
<TableCell className="tabular-nums">
|
||||
{new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.docType === "Invoice" ? (
|
||||
<Link href={`/dashboard/sales/invoices/${row.docId}`} className="text-primary hover:underline">{row.docNo}</Link>
|
||||
) : (
|
||||
row.docNo
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{row.docType}</TableCell>
|
||||
<TableCell>{row.customerName}</TableCell>
|
||||
<TableCell>{row.paidBy}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(row.total)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(row.paid)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(row.due, true)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(row.cost)}</TableCell>
|
||||
<TableCell className={cn("text-right tabular-nums", row.grossProfit < 0 && "text-destructive")}>
|
||||
{formatAmount(row.grossProfit)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="font-semibold">Total</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.totalSales)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.paidAmount)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.dueAmount)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.costOfGoodsSold)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.grossProfit)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-sm font-semibold text-foreground">Expenses of the day</h2>
|
||||
{expenses.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No posted expenses on {formatReportDate(date)}.</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Doc no.</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Payee</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{expenses.map((e) => (
|
||||
<TableRow key={e.expenseId}>
|
||||
<TableCell>
|
||||
<Link href={`/dashboard/accounts/expenses/${e.expenseId}`} className="text-primary hover:underline">{e.docNo}</Link>
|
||||
</TableCell>
|
||||
<TableCell>{e.categoryName}</TableCell>
|
||||
<TableCell>{e.titleName}</TableCell>
|
||||
<TableCell>{e.payee ?? "—"}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(e.amount)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(e.paidAmount)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="font-semibold">Total</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(s.expenseTotal)}</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProfitStatement day={s} />
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* --- Day-wise income ----------------------------------------------------------------- */}
|
||||
<section className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-5 shadow-[var(--shadow-panel)]">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">Day-wise income</h2>
|
||||
<p className="text-sm text-muted-foreground">Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="der-from">From</Label>
|
||||
<Input id="der-from" type="date" value={from} max={to || localIso()} onChange={(e) => setFrom(e.target.value)} className="w-44" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="der-to">To</Label>
|
||||
<Input id="der-to" type="date" value={to} min={from} max={localIso()} onChange={(e) => setTo(e.target.value)} className="w-44" />
|
||||
</div>
|
||||
<Button onClick={downloadRange} disabled={!range?.data || downloading !== null}>
|
||||
<FileDown className="size-4" />
|
||||
{downloading === "range" ? "Creating PDF…" : "Download PDF"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rangeError && <p className="text-sm text-destructive">{rangeError}</p>}
|
||||
{!rangeError && range?.error && <p className="text-sm text-destructive">{range.error}</p>}
|
||||
{!rangeError && !range && <Skeleton className="h-72 rounded-xl" />}
|
||||
|
||||
{!rangeError && range?.data && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard label="Sales" value={formatAmount(range.data.totals.totalSales)} icon={ReceiptText} />
|
||||
<StatCard label="Expenses" value={formatAmount(range.data.totals.expenseTotal)} icon={Receipt} />
|
||||
<StatCard
|
||||
label={range.data.totals.income < 0 ? "Income (loss)" : "Income"}
|
||||
value={formatSignedAmount(range.data.totals.income)}
|
||||
icon={range.data.totals.income >= 0 ? TrendingUp : TrendingDown}
|
||||
className={range.data.totals.income < 0 ? "bg-destructive/10 ring-destructive/30" : undefined}
|
||||
/>
|
||||
<StatCard
|
||||
label={`Net ${profitOrLoss(range.data.totals.netProfit).label.toLowerCase()}`}
|
||||
value={profitOrLoss(range.data.totals.netProfit).amount}
|
||||
icon={range.data.totals.netProfit >= 0 ? TrendingUp : TrendingDown}
|
||||
className={range.data.totals.netProfit < 0 ? "bg-destructive/10 ring-destructive/30" : "bg-success/10 ring-success/30"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DayEndIncomeChart days={range.data.days} />
|
||||
|
||||
<div className="max-h-[28rem] overflow-auto rounded-lg border border-border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-card">
|
||||
<TableRow>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead className="text-right">Sales</TableHead>
|
||||
<TableHead className="text-right">Paid</TableHead>
|
||||
<TableHead className="text-right">Due</TableHead>
|
||||
<TableHead className="text-right">Cash</TableHead>
|
||||
<TableHead className="text-right">Card</TableHead>
|
||||
<TableHead className="text-right">Count</TableHead>
|
||||
<TableHead className="text-right">Expenses</TableHead>
|
||||
<TableHead className="text-right">Income</TableHead>
|
||||
<TableHead className="text-right">Profit / loss</TableHead>
|
||||
<TableHead className="text-right">Deposited</TableHead>
|
||||
<TableHead className="text-right">Cash in hand</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{[...range.data.days].reverse().map((d) => (
|
||||
<TableRow key={d.date} className="cursor-pointer" onClick={() => d.date && setDate(d.date)} title="Show this day above">
|
||||
<TableCell>{formatReportDate(d.date)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.totalSales)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidAmount)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.dueAmount)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCash)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.paidByCard)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{d.salesCount}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.expenseTotal)}</TableCell>
|
||||
<IncomeCell value={d.income} />
|
||||
<ProfitCell value={d.netProfit} />
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.cashDeposited ?? 0, true)}</TableCell>
|
||||
<TableCell className="text-right tabular-nums">{formatAmount(d.closingCashInHand ?? 0)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow>
|
||||
<TableCell className="font-semibold">Total</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.totalSales)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidAmount)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.dueAmount)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCash)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.paidByCard)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{range.data.totals.salesCount}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.expenseTotal)}</TableCell>
|
||||
<IncomeCell value={range.data.totals.income} bold />
|
||||
<ProfitCell value={range.data.totals.netProfit} bold />
|
||||
<TableCell className="text-right font-semibold tabular-nums">{formatAmount(range.data.totals.cashDeposited ?? 0)}</TableCell>
|
||||
<TableCell className="text-right font-semibold tabular-nums" title="Cash in hand at the end of the last day">
|
||||
{formatAmount(range.data.totals.closingCashInHand ?? 0)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Save } from "lucide-react"
|
||||
|
||||
import { approvalsApi } from "@/lib/api/approvals"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { ApprovalConfig } from "@/types/approvals"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldDescription, 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 { toast } from "@/components/ui/toast"
|
||||
|
||||
const levelOptions = [
|
||||
{ value: "2", label: "Two approvals — two different people" },
|
||||
{ value: "1", label: "One approval" },
|
||||
{ value: "0", label: "No approval — applies immediately" },
|
||||
]
|
||||
|
||||
/** Settings → Approvals (docs/DUAL_APPROVAL_PLAN.md §7): how many approvals stock and price adjustments need. */
|
||||
export default function ApprovalSettingsPage() {
|
||||
const [config, setConfig] = useState<ApprovalConfig | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [stockLevels, setStockLevels] = useState("2")
|
||||
const [stockThreshold, setStockThreshold] = useState("0")
|
||||
const [priceLevels, setPriceLevels] = useState("2")
|
||||
const [priceThreshold, setPriceThreshold] = useState("0")
|
||||
const [roles, setRoles] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
function apply(c: ApprovalConfig) {
|
||||
setConfig(c)
|
||||
setStockLevels(String(c.stockAdjustmentLevels))
|
||||
setStockThreshold(String(c.stockAdjustmentLevel2ThresholdValue))
|
||||
setPriceLevels(String(c.priceAdjustmentLevels))
|
||||
setPriceThreshold(String(c.priceAdjustmentLevel2ThresholdPercent))
|
||||
setRoles(c.level2RoleCodes.join(", "))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
approvalsApi.getConfig().then(apply).catch((err) => setError(errorMessage(err)))
|
||||
}, [])
|
||||
|
||||
async function save() {
|
||||
const sv = Number(stockThreshold)
|
||||
const pv = Number(priceThreshold)
|
||||
if (Number.isNaN(sv) || sv < 0 || Number.isNaN(pv) || pv < 0) {
|
||||
toast.error("Check the thresholds", "Thresholds must be 0 or more.")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const saved = await approvalsApi.updateConfig({
|
||||
stockAdjustmentLevels: Number(stockLevels),
|
||||
stockAdjustmentLevel2ThresholdValue: sv,
|
||||
priceAdjustmentLevels: Number(priceLevels),
|
||||
priceAdjustmentLevel2ThresholdPercent: pv,
|
||||
level2RoleCodes: roles.split(",").map((r) => r.trim()).filter(Boolean),
|
||||
})
|
||||
apply(saved)
|
||||
toast.success("Approval settings saved", "They apply to requests made from now on.")
|
||||
} catch (err) {
|
||||
toast.error("Could not save approval settings", errorMessage(err))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
if (!config) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Approvals</h1>
|
||||
<p className="text-base text-muted-foreground">
|
||||
How many people must approve a stock or price adjustment before it takes effect. The requester can never approve their own request,
|
||||
and with two approvals the second must be a different person from the first. Changes apply to new requests; pending ones keep their rule.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-foreground">Stock adjustments</h2>
|
||||
<p className="text-sm text-muted-foreground">Adjustments, wastage and stock-count variances.</p>
|
||||
<Field>
|
||||
<FieldLabel>Approvals required</FieldLabel>
|
||||
<Select<string> value={stockLevels} onValueChange={(v) => v && setStockLevels(v)}>
|
||||
<SelectTrigger className="h-11! w-full text-base"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{levelOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-base">{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{stockLevels === "2" && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="stock-threshold">Second approval only from value (LKR)</FieldLabel>
|
||||
<Input id="stock-threshold" type="number" min="0" step="0.01" value={stockThreshold} onChange={(e) => setStockThreshold(e.target.value)} className="h-11 text-base" />
|
||||
<FieldDescription>Adjustments worth less than this (at FIFO cost) need only one approval. 0 = always two.</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-foreground">Price adjustments</h2>
|
||||
<p className="text-sm text-muted-foreground">Selling-price changes. While this is not “No approval”, prices can’t be edited directly on an item.</p>
|
||||
<Field>
|
||||
<FieldLabel>Approvals required</FieldLabel>
|
||||
<Select<string> value={priceLevels} onValueChange={(v) => v && setPriceLevels(v)}>
|
||||
<SelectTrigger className="h-11! w-full text-base"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{levelOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-base">{o.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{priceLevels === "2" && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="price-threshold">Second approval only from change of (%)</FieldLabel>
|
||||
<Input id="price-threshold" type="number" min="0" step="0.1" value={priceThreshold} onChange={(e) => setPriceThreshold(e.target.value)} className="h-11 text-base" />
|
||||
<FieldDescription>Changes smaller than this need only one approval. Setting or clearing a price counts as 100%. 0 = always two.</FieldDescription>
|
||||
</Field>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-2xl border bg-card p-5 shadow-sm">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="level2-roles">Who can give the second approval (role codes)</FieldLabel>
|
||||
<Input id="level2-roles" value={roles} onChange={(e) => setRoles(e.target.value)} placeholder="e.g. Manager, Accountant — leave empty for anyone with the Approve permission" className="h-11 text-base" />
|
||||
<FieldDescription>
|
||||
Comma-separated. Anyone giving an approval also needs the Approve permission on the screen (Settings → Roles).
|
||||
Make sure at least two people per branch can approve, or requests will wait forever.
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button size="lg" onClick={save} disabled={saving}>
|
||||
<Save className="size-5" />
|
||||
{saving ? "Saving…" : "Save settings"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import { use, useCallback, useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { StockAdjustment } from "@/types/stock"
|
||||
|
||||
import { ApprovalPanel } from "@/components/approvals/ApprovalPanel"
|
||||
import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
|
||||
/** One stock adjustment: its lines, its approval trail, and Approve / Reject / Withdraw (docs/DUAL_APPROVAL_PLAN.md §10). */
|
||||
export default function AdjustmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
const adjustmentId = Number(id)
|
||||
|
||||
const [adjustment, setAdjustment] = useState<StockAdjustment | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(adjustmentId) ? loadError : `Invalid adjustment id '${id}'.`
|
||||
const [itemLabels, setItemLabels] = useState<Map<number, string>>(new Map())
|
||||
const [warehouse, setWarehouse] = useState<string | null>(null)
|
||||
const [reason, setReason] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!Number.isFinite(adjustmentId)) return
|
||||
stockAdjustmentsApi.get(adjustmentId).then(setAdjustment).catch((err) => setLoadError(errorMessage(err)))
|
||||
}, [adjustmentId])
|
||||
|
||||
useEffect(load, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (!adjustment) return
|
||||
itemsApi.list({ pageSize: 200 }).then((r) => setItemLabels(new Map(r.items.map((i) => [i.itemId, `${i.sku} — ${i.name}`])))).catch(() => {})
|
||||
warehousesApi.list().then((r) => setWarehouse(r.items.find((w) => w.warehouseId === adjustment.warehouseId)?.code ?? null)).catch(() => {})
|
||||
reasonCodesApi.list("Adjustment").then((r) => setReason(r.items.find((c) => c.reasonCodeId === adjustment.reasonCodeId)?.description ?? null)).catch(() => {})
|
||||
}, [adjustment])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<BackLink />
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!adjustment) return <Skeleton className="h-96 w-full" />
|
||||
|
||||
const a = adjustment
|
||||
const outcome = a.status === "Posted" ? "applied" : a.status === "Rejected" ? "rejected" : a.status === "Cancelled" ? "withdrawn" : "pending"
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<BackLink />
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-foreground">{a.docNo}</h1>
|
||||
<AdjustmentStatusBadge status={a.status} />
|
||||
</div>
|
||||
<p className="text-base text-muted-foreground">
|
||||
{warehouse ?? `Warehouse #${a.warehouseId}`} · {reason ?? `Reason #${a.reasonCodeId}`}
|
||||
{a.glJournalNo ? ` · Journal ${a.glJournalNo}` : ""}
|
||||
</p>
|
||||
{outcome === "pending" && (
|
||||
<p className="text-sm text-warning">This is a request — stock has not moved and nothing is posted until it is fully approved.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_24rem]">
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<Table className="text-base">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="h-11 px-4 text-sm">Item</TableHead>
|
||||
<TableHead className="h-11 px-4 text-right text-sm">Qty change</TableHead>
|
||||
<TableHead className="h-11 px-4 text-sm">Serials</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{a.lines.map((l) => (
|
||||
<TableRow key={l.adjLineId}>
|
||||
<TableCell className="px-4 py-3">{itemLabels.get(l.itemId) ?? `Item #${l.itemId}`}</TableCell>
|
||||
<TableCell className={`px-4 py-3 text-right font-semibold tabular-nums ${l.qtyDelta < 0 ? "text-destructive" : "text-success"}`}>
|
||||
{l.qtyDelta > 0 ? `+${l.qtyDelta}` : l.qtyDelta}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{[...(l.requestedSerialNos ?? []), ...(l.requestedSerialIds ?? []).map((s) => `#${s}`)].join(", ") || "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<ApprovalPanel
|
||||
what="stock adjustment"
|
||||
requiredApprovals={a.requiredApprovals}
|
||||
pendingLevel={a.pendingLevel}
|
||||
history={a.approvalHistory ?? []}
|
||||
requestedByLabel={a.createdByName ?? `user #${a.createdBy}`}
|
||||
requestedAt={a.createdAt}
|
||||
canApprove={a.canApprove}
|
||||
whyNot={a.whyNot}
|
||||
canWithdraw={a.canWithdraw}
|
||||
finalEffect="Stock moves and the inventory journal is posted to the ledger."
|
||||
outcome={outcome}
|
||||
outcomeAt={a.postedAt}
|
||||
rejectReason={a.rejectReason}
|
||||
onApprove={async (comment) => {
|
||||
try {
|
||||
const next = await stockAdjustmentsApi.approve(a.adjustmentId, comment)
|
||||
setAdjustment(next)
|
||||
toast.success(next.status === "Posted" ? "Approved and posted" : "Approved", next.status === "Posted" ? `${next.docNo} — stock updated.` : `${next.docNo} now needs a second approval.`)
|
||||
} catch (err) {
|
||||
toast.error("Could not approve", errorMessage(err))
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
onReject={async (reasonText) => {
|
||||
try {
|
||||
setAdjustment(await stockAdjustmentsApi.reject(a.adjustmentId, reasonText))
|
||||
toast.success("Adjustment rejected", a.docNo)
|
||||
} catch (err) {
|
||||
toast.error("Could not reject", errorMessage(err))
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
onWithdraw={async () => {
|
||||
try {
|
||||
setAdjustment(await stockAdjustmentsApi.cancel(a.adjustmentId))
|
||||
toast.success("Request withdrawn", a.docNo)
|
||||
} catch (err) {
|
||||
toast.error("Could not withdraw", errorMessage(err))
|
||||
throw err
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<Link href="/dashboard/stock/adjustments" className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" />
|
||||
Stock Adjustments
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -187,10 +187,11 @@ export default function NewAdjustmentPage() {
|
||||
lines: payloadLines,
|
||||
})
|
||||
setResult(adjustment)
|
||||
toast.success("Adjustment posted", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
|
||||
if (adjustment.status === "Posted") toast.success("Adjustment posted", `${adjustment.docNo} — stock updated.`)
|
||||
else toast.success("Submitted for approval", `${adjustment.docNo} moves stock once ${adjustment.requiredApprovals === 2 ? "two people approve it" : "it is approved"}.`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not post adjustment", errorMessage(err))
|
||||
toast.error("Could not submit adjustment", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
@@ -224,9 +225,20 @@ export default function NewAdjustmentPage() {
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">{result.docNo} posted</p>
|
||||
<p className="text-base font-semibold">
|
||||
{result.status === "Posted" ? `${result.docNo} posted` : `${result.docNo} submitted for approval`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{result.status === "Posted" ? (
|
||||
<>Ledger refs: {result.ledgerRefs.join(", ")}</>
|
||||
) : (
|
||||
<>
|
||||
Stock has not moved yet — it needs {result.requiredApprovals === 2 ? "two approvals from two different people" : "one approval"} (not yours).{" "}
|
||||
<Link href={`/dashboard/stock/adjustments/${result.adjustmentId}`} className="font-medium text-primary hover:underline">View request</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {result.ledgerRefs.join(", ")}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
@@ -413,7 +425,7 @@ export default function NewAdjustmentPage() {
|
||||
Cancel
|
||||
</Link>
|
||||
<Button size="lg" type="button" className="w-full sm:w-auto" onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? "Posting…" : "Post Adjustment"}
|
||||
{submitting ? "Submitting…" : "Submit for approval"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus, Search, SlidersHorizontal } from "lucide-react"
|
||||
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
@@ -10,24 +11,38 @@ import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
import { StockAdjustmentSummary } from "@/types/stock"
|
||||
import { AdjustmentStatus, StockAdjustmentSummary } from "@/types/stock"
|
||||
import { Warehouse } from "@/types/master-data"
|
||||
|
||||
import { useAuth } from "@/components/auth/AuthProvider"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { PaginationFooter } from "@/components/ui/pagination-footer"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { AdjustmentStatusBadge } from "@/components/stock/status-badges"
|
||||
import { ApprovalQueueButton } from "@/components/approvals/ApprovalQueueButton"
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 15, 20]
|
||||
// Mobile has no page-size selector — tapping "Show more" just grows the page size and
|
||||
// re-fetches from page 1, so previously loaded cards stay put and more append below.
|
||||
const MOBILE_PAGE_SIZE_STEP = 10
|
||||
|
||||
type StatusFilter = AdjustmentStatus | "All"
|
||||
const statusOptions: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "All", label: "All statuses" },
|
||||
{ value: "PendingApproval", label: "Pending 1st approval" },
|
||||
{ value: "PendingSecondApproval", label: "Pending 2nd approval" },
|
||||
{ value: "Posted", label: "Posted" },
|
||||
{ value: "Rejected", label: "Rejected" },
|
||||
{ value: "Cancelled", label: "Withdrawn" },
|
||||
]
|
||||
|
||||
export default function AdjustmentsListPage() {
|
||||
const { activeBranchId, branchingEnabled } = useAuth()
|
||||
const router = useRouter()
|
||||
const [status, setStatus] = useState<StatusFilter>("All")
|
||||
|
||||
const [adjustments, setAdjustments] = useState<StockAdjustmentSummary[] | null>(null)
|
||||
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null)
|
||||
@@ -46,19 +61,22 @@ export default function AdjustmentsListPage() {
|
||||
return () => clearTimeout(timeout)
|
||||
}, [searchInput])
|
||||
|
||||
useEffect(() => setPage(1), [query])
|
||||
useEffect(() => setPage(1), [query, status])
|
||||
|
||||
useEffect(() => {
|
||||
setError(null)
|
||||
stockAdjustmentsApi
|
||||
.list({ page, pageSize, q: query || undefined, branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined })
|
||||
.list({
|
||||
page, pageSize, q: query || undefined, status: status === "All" ? undefined : status,
|
||||
branchId: branchingEnabled ? (activeBranchId ?? undefined) : undefined,
|
||||
})
|
||||
.then((res) => {
|
||||
setAdjustments(res.items)
|
||||
setPagination(res.pagination)
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err)))
|
||||
.finally(() => setShowMoreLoading(false))
|
||||
}, [page, pageSize, query, activeBranchId, branchingEnabled])
|
||||
}, [page, pageSize, query, status, activeBranchId, branchingEnabled])
|
||||
|
||||
function handleShowMore() {
|
||||
setShowMoreLoading(true)
|
||||
@@ -75,7 +93,8 @@ export default function AdjustmentsListPage() {
|
||||
}, [])
|
||||
|
||||
const whCode = (id: number) => warehouses?.find((w) => w.warehouseId === id)?.code ?? `#${id}`
|
||||
const hasFilters = query.length > 0
|
||||
const hasFilters = query.length > 0 || status !== "All"
|
||||
const open = (id: number) => router.push(`/dashboard/stock/adjustments/${id}`)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
@@ -83,13 +102,18 @@ export default function AdjustmentsListPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground">Stock Adjustments</h1>
|
||||
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
|
||||
<p className="text-base text-muted-foreground">
|
||||
Increase, decrease, or write off stock with a reason code (FR-STK-07). Each adjustment is a request — stock moves once it is approved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Adjustment
|
||||
</Link>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<ApprovalQueueButton docType="ADJ" />
|
||||
<Link href="/dashboard/stock/adjustments/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
New Adjustment
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
@@ -103,6 +127,14 @@ export default function AdjustmentsListPage() {
|
||||
aria-label="Search stock adjustments"
|
||||
/>
|
||||
</div>
|
||||
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
|
||||
<SelectTrigger className="h-12! w-full text-base sm:w-56" aria-label="Status">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((o) => <SelectItem key={o.value} value={o.value} className="text-base">{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -147,7 +179,14 @@ export default function AdjustmentsListPage() {
|
||||
{/* Mobile: stacked cards. */}
|
||||
<div className="flex flex-col gap-3 md:hidden">
|
||||
{adjustments.map((a) => (
|
||||
<div key={a.adjustmentId} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm">
|
||||
<div
|
||||
key={a.adjustmentId}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
onClick={() => open(a.adjustmentId)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") open(a.adjustmentId) }}
|
||||
className="flex cursor-pointer flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="truncate font-semibold text-foreground">{a.docNo}</p>
|
||||
<AdjustmentStatusBadge status={a.status} />
|
||||
@@ -197,8 +236,10 @@ export default function AdjustmentsListPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adjustments.map((a) => (
|
||||
<TableRow key={a.adjustmentId} className="align-top hover:bg-muted/20">
|
||||
<TableCell className="font-semibold text-foreground">{a.docNo}</TableCell>
|
||||
<TableRow key={a.adjustmentId} className="cursor-pointer align-top hover:bg-muted/20" onClick={() => open(a.adjustmentId)}>
|
||||
<TableCell className="font-semibold text-foreground">
|
||||
<Link href={`/dashboard/stock/adjustments/${a.adjustmentId}`} className="hover:underline">{a.docNo}</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{whCode(a.warehouseId)}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{reasonLabels.get(a.reasonCodeId) ?? `#${a.reasonCodeId}`}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import { CheckCircle2, ClipboardCheck, Save } from "lucide-react"
|
||||
|
||||
@@ -103,7 +104,13 @@ export default function CountDetailPage() {
|
||||
const result = await stockCountsApi.post(countId)
|
||||
setPostResult(result)
|
||||
setCount((prev) => (prev ? { ...prev, status: result.status } : prev))
|
||||
toast.success("Count posted", `Variance adjustment ${result.adjustmentId} created.`)
|
||||
const pending = result.adjustmentStatus && result.adjustmentStatus !== "Posted"
|
||||
toast.success(
|
||||
"Count posted",
|
||||
result.adjustmentId === null ? "No variances — nothing to adjust."
|
||||
: pending ? `Variances submitted for approval as ${result.adjustmentDocNo ?? `adjustment #${result.adjustmentId}`}.`
|
||||
: `Variance adjustment ${result.adjustmentDocNo ?? result.adjustmentId} posted.`
|
||||
)
|
||||
} catch (err) {
|
||||
setError(errorMessage(err))
|
||||
toast.error("Could not post count", errorMessage(err))
|
||||
@@ -171,9 +178,22 @@ export default function CountDetailPage() {
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">Posted — variance adjustment #{postResult.adjustmentId} created</p>
|
||||
<p className="text-base font-semibold">
|
||||
{postResult.adjustmentId === null
|
||||
? "Posted — no variances"
|
||||
: postResult.adjustmentStatus && postResult.adjustmentStatus !== "Posted"
|
||||
? `Posted — variances submitted for approval (${postResult.adjustmentDocNo ?? `#${postResult.adjustmentId}`})`
|
||||
: `Posted — variance adjustment ${postResult.adjustmentDocNo ?? `#${postResult.adjustmentId}`} created`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</div>
|
||||
{postResult.adjustmentId !== null && postResult.adjustmentStatus && postResult.adjustmentStatus !== "Posted" ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Stock moves once the variance adjustment is approved.{" "}
|
||||
<Link href={`/dashboard/stock/adjustments/${postResult.adjustmentId}`} className="font-medium text-primary hover:underline">View request</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {postResult.ledgerRefs.join(", ") || "none (no variance)"}</div>
|
||||
)}
|
||||
{postResult.glJournalNo && (
|
||||
<div className="text-sm text-muted-foreground">GL journal: <span className="font-medium text-foreground">{postResult.glJournalNo}</span></div>
|
||||
)}
|
||||
|
||||
@@ -111,7 +111,8 @@ export default function NewWastagePage() {
|
||||
reasonCodeId: reasonCodeId as number,
|
||||
})
|
||||
setResult(adjustment)
|
||||
toast.success("Wastage recorded", `${adjustment.docNo} posted immediately (auto-post, FR-STK-07).`)
|
||||
if (adjustment.status === "Posted") toast.success("Wastage recorded", `${adjustment.docNo} — stock written off.`)
|
||||
else toast.success("Wastage submitted for approval", `${adjustment.docNo} is written off once ${adjustment.requiredApprovals === 2 ? "two people approve it" : "it is approved"}.`)
|
||||
} catch (err) {
|
||||
setSubmitError(errorMessage(err))
|
||||
toast.error("Could not record wastage", errorMessage(err))
|
||||
@@ -152,9 +153,20 @@ export default function NewWastagePage() {
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-success/30 bg-success/5 p-5">
|
||||
<div className="flex items-center gap-2 text-success">
|
||||
<CheckCircle2 className="size-6" />
|
||||
<p className="text-base font-semibold">{result.docNo} posted</p>
|
||||
<p className="text-base font-semibold">
|
||||
{result.status === "Posted" ? `${result.docNo} posted` : `${result.docNo} submitted for approval`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{result.status === "Posted" ? (
|
||||
<>Ledger refs: {result.ledgerRefs.join(", ")}</>
|
||||
) : (
|
||||
<>
|
||||
Stock is written off once approved.{" "}
|
||||
<Link href={`/dashboard/stock/adjustments/${result.adjustmentId}`} className="font-medium text-primary hover:underline">View request</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">Ledger refs: {result.ledgerRefs.join(", ")}</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { warehousesApi } from "@/lib/api/warehouses"
|
||||
import { itemsApi } from "@/lib/api/items"
|
||||
import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ApprovalQueueButton } from "@/components/approvals/ApprovalQueueButton"
|
||||
import { ReasonCode } from "@/types/stock"
|
||||
import { ItemListItem, Warehouse } from "@/types/master-data"
|
||||
|
||||
@@ -92,10 +93,13 @@ export default function WastagePage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
Record Wastage
|
||||
</Link>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<ApprovalQueueButton docType="ADJ" />
|
||||
<Link href="/dashboard/stock/wastage/new" className={cn(buttonVariants({ size: "lg" }), "w-full sm:w-auto")}>
|
||||
<Plus className="size-5" />
|
||||
Record Wastage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -200,7 +204,7 @@ export default function WastagePage() {
|
||||
<div className="grid grid-cols-2 gap-3 border-t pt-3 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">Doc No</p>
|
||||
<p className="font-medium text-foreground">{r.docNo}</p>
|
||||
<p className="font-medium text-foreground">{r.docNo}{r.status !== "Posted" && <span className="ml-2 rounded bg-warning/10 px-1.5 py-0.5 text-xs text-warning">Pending approval</span>}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">Warehouse</p>
|
||||
@@ -253,7 +257,7 @@ export default function WastagePage() {
|
||||
const reason = reasonById.get(r.reasonCodeId)
|
||||
return (
|
||||
<TableRow key={`${r.adjustmentId}-${r.adjLineId}`} className="align-top hover:bg-muted/20">
|
||||
<TableCell className="font-semibold text-foreground">{r.docNo}</TableCell>
|
||||
<TableCell className="font-semibold text-foreground">{r.docNo}{r.status !== "Posted" && <span className="ml-2 rounded bg-warning/10 px-1.5 py-0.5 text-xs font-normal text-warning">Pending approval</span>}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{new Date(r.createdAt).toLocaleString()}</TableCell>
|
||||
<TableCell className="font-semibold text-foreground">{item?.sku ?? `#${r.itemId}`}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{item?.name}</TableCell>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Approvals inbox and settings (docs/DUAL_APPROVAL_PLAN.md §7, §10), plus price adjustments (§6).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import {
|
||||
ApprovalConfig,
|
||||
CreatePriceAdjustmentRequest,
|
||||
ItemPriceHistory,
|
||||
PendingApprovals,
|
||||
PriceAdjustment,
|
||||
PriceAdjustmentStatus,
|
||||
PriceAdjustmentSummary,
|
||||
UpdateApprovalConfigRequest,
|
||||
} from "@/types/approvals"
|
||||
|
||||
export const approvalsApi = {
|
||||
/** What you can approve, plus your own requests that are waiting for others. */
|
||||
pending(): Promise<PendingApprovals> {
|
||||
return apiRequest<PendingApprovals>("/approvals/pending")
|
||||
},
|
||||
|
||||
getConfig(): Promise<ApprovalConfig> {
|
||||
return apiRequest<ApprovalConfig>("/approvals/config")
|
||||
},
|
||||
|
||||
/** Needs settings.approvals. */
|
||||
updateConfig(request: UpdateApprovalConfigRequest): Promise<ApprovalConfig> {
|
||||
return apiRequest<ApprovalConfig>("/approvals/config", { method: "PUT", body: request })
|
||||
},
|
||||
}
|
||||
|
||||
export const priceAdjustmentsApi = {
|
||||
list(params: { page?: number; pageSize?: number; q?: string; status?: PriceAdjustmentStatus; itemId?: number } = {}): Promise<PagedResponse<PriceAdjustmentSummary>> {
|
||||
return apiRequest<PagedResponse<PriceAdjustmentSummary>>(`/price-adjustments${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(priceAdjustmentId: number): Promise<PriceAdjustment> {
|
||||
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}`)
|
||||
},
|
||||
|
||||
/** 409 PRICE_ADJUSTMENT_ITEM_PENDING when an item already has a pending request. */
|
||||
create(request: CreatePriceAdjustmentRequest): Promise<PriceAdjustment> {
|
||||
return apiRequest<PriceAdjustment>("/price-adjustments", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** The final approval applies the prices; 409 PRICE_ADJUSTMENT_STALE if an item was repriced meanwhile. */
|
||||
approve(priceAdjustmentId: number, comment?: string): Promise<PriceAdjustment> {
|
||||
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/approve`, { method: "POST", body: { comment } })
|
||||
},
|
||||
|
||||
reject(priceAdjustmentId: number, reason: string): Promise<PriceAdjustment> {
|
||||
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/reject`, { method: "POST", body: { reason } })
|
||||
},
|
||||
|
||||
/** Requester only, while pending. */
|
||||
cancel(priceAdjustmentId: number): Promise<PriceAdjustment> {
|
||||
return apiRequest<PriceAdjustment>(`/price-adjustments/${priceAdjustmentId}/cancel`, { method: "POST" })
|
||||
},
|
||||
|
||||
itemHistory(itemId: number): Promise<ItemPriceHistory[]> {
|
||||
return apiRequest<ItemPriceHistory[]>(`/items/${itemId}/price-history`)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Bank deposits of till cash (docs/BANK_DEPOSIT_MODULE_PLAN.md §7.3). create() posts GL (Debit bank /
|
||||
// Credit cash) and is rejected above the cash in hand; cancel() posts the reversing entry.
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import {
|
||||
BankDeposit,
|
||||
BankDepositList,
|
||||
BankDepositListFilterParams,
|
||||
CashInHand,
|
||||
CashInHandDaily,
|
||||
CreateBankDepositRequest,
|
||||
} from "@/types/bank-deposits"
|
||||
|
||||
export const bankDepositsApi = {
|
||||
list(params: BankDepositListFilterParams = {}): Promise<BankDepositList> {
|
||||
return apiRequest<BankDepositList>(`/bank-deposits${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(bankDepositId: number): Promise<BankDeposit> {
|
||||
return apiRequest<BankDeposit>(`/bank-deposits/${bankDepositId}`)
|
||||
},
|
||||
|
||||
/** 400 BANK_DEPOSIT_EXCEEDS_CASH_IN_HAND when the amount is more than can still be deposited for that day. */
|
||||
create(request: CreateBankDepositRequest): Promise<BankDeposit> {
|
||||
return apiRequest<BankDeposit>("/bank-deposits", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Posted only; puts the cash back in hand. */
|
||||
cancel(bankDepositId: number, reason: string): Promise<BankDeposit> {
|
||||
return apiRequest<BankDeposit>(`/bank-deposits/${bankDepositId}/cancel`, { method: "POST", body: { reason } })
|
||||
},
|
||||
|
||||
/** `date` omitted ⇒ today in the business time zone. `branchId` is ignored for branch users. */
|
||||
cashInHand(params: { date?: string; branchId?: number | null } = {}): Promise<CashInHand> {
|
||||
return apiRequest<CashInHand>(`/bank-deposits/cash-in-hand${buildQuery({ date: params.date, branchId: params.branchId ?? undefined })}`)
|
||||
},
|
||||
|
||||
/** At most 92 days. */
|
||||
cashInHandDaily(params: { from: string; to: string; branchId?: number | null }): Promise<CashInHandDaily> {
|
||||
return apiRequest<CashInHandDaily>(
|
||||
`/bank-deposits/cash-in-hand/daily${buildQuery({ from: params.from, to: params.to, branchId: params.branchId ?? undefined })}`
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md §4). Read-only: one day's sales,
|
||||
// payments by method, expenses and income, and the same day-wise over a range (≤ 92 days).
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { DayEndDailyReport, DayEndReport } from "@/types/sales"
|
||||
|
||||
export const dayEndReportApi = {
|
||||
/** `date` omitted ⇒ today in the report's time zone. `branchId` is ignored for non-admins. */
|
||||
getDay(params: { date?: string; branchId?: number | null } = {}): Promise<DayEndReport> {
|
||||
return apiRequest<DayEndReport>(`/day-end-report${buildQuery({ date: params.date, branchId: params.branchId ?? undefined })}`)
|
||||
},
|
||||
|
||||
getDaily(params: { from: string; to: string; branchId?: number | null }): Promise<DayEndDailyReport> {
|
||||
return apiRequest<DayEndDailyReport>(
|
||||
`/day-end-report/daily${buildQuery({ from: params.from, to: params.to, branchId: params.branchId ?? undefined })}`
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// One typed client method per Expense endpoint (docs/EXPENSE_MODULE_PLAN.md §6). Draft
|
||||
// expenses are editable via update(); post() accrues to GL, pay()/postAndPay() settle it.
|
||||
import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client"
|
||||
import { ApiResult, PagedResponse } from "@/types/common"
|
||||
import {
|
||||
CreateExpensePaymentRequest,
|
||||
CreateExpenseRequest,
|
||||
CreateExpenseTitleRequest,
|
||||
Expense,
|
||||
ExpenseCategory,
|
||||
ExpenseListFilterParams,
|
||||
ExpensePayment,
|
||||
ExpenseSummary,
|
||||
ExpenseSummaryReport,
|
||||
ExpenseTitle,
|
||||
UpdateExpenseCategoryRequest,
|
||||
UpdateExpenseRequest,
|
||||
UpdateExpenseTitleRequest,
|
||||
} from "@/types/expenses"
|
||||
|
||||
export const expenseSetupApi = {
|
||||
/** Categories with their titles, in display order. Inactive rows only when asked for. */
|
||||
list(includeInactive = false): Promise<ExpenseCategory[]> {
|
||||
return apiRequest<ExpenseCategory[]>(`/expense-categories${buildQuery({ includeInactive: includeInactive || undefined })}`)
|
||||
},
|
||||
|
||||
/** Categories are fixed (no create); in practice only the active flag changes — they're all built-in. */
|
||||
updateCategory(expenseCategoryId: number, request: UpdateExpenseCategoryRequest): Promise<ExpenseCategory> {
|
||||
return apiRequest<ExpenseCategory>(`/expense-categories/${expenseCategoryId}`, { method: "PUT", body: request })
|
||||
},
|
||||
|
||||
createTitle(expenseCategoryId: number, request: CreateExpenseTitleRequest): Promise<ExpenseTitle> {
|
||||
return apiRequest<ExpenseTitle>(`/expense-categories/${expenseCategoryId}/titles`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** 409 EXPENSE_SYSTEM_ROW_READONLY when renaming a built-in title (GL code and status stay editable). */
|
||||
updateTitle(expenseTitleId: number, request: UpdateExpenseTitleRequest): Promise<ExpenseTitle> {
|
||||
return apiRequest<ExpenseTitle>(`/expense-titles/${expenseTitleId}`, { method: "PUT", body: request })
|
||||
},
|
||||
}
|
||||
|
||||
export const expensesApi = {
|
||||
list(params: ExpenseListFilterParams = {}): Promise<PagedResponse<ExpenseSummary>> {
|
||||
return apiRequest<PagedResponse<ExpenseSummary>>(`/expenses${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
/** Posted expenses dated in [from, to] (default: this month), plus the unpaid/overdue position. */
|
||||
summary(params: { from?: string; to?: string } = {}): Promise<ExpenseSummaryReport> {
|
||||
return apiRequest<ExpenseSummaryReport>(`/expenses/summary${buildQuery(params)}`)
|
||||
},
|
||||
|
||||
get(expenseId: number): Promise<ApiResult<Expense>> {
|
||||
return apiRequestWithETag<Expense>(`/expenses/${expenseId}`)
|
||||
},
|
||||
|
||||
create(request: CreateExpenseRequest): Promise<ApiResult<Expense>> {
|
||||
return apiRequestWithETag<Expense>("/expenses", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Draft only (409 EXPENSE_NOT_DRAFT otherwise). Echo the ETag from get()/create() as ifMatch. */
|
||||
update(expenseId: number, request: UpdateExpenseRequest, ifMatch: string): Promise<ApiResult<Expense>> {
|
||||
return apiRequestWithETag<Expense>(`/expenses/${expenseId}`, { method: "PUT", body: request, ifMatch })
|
||||
},
|
||||
|
||||
/** Draft only. */
|
||||
remove(expenseId: number): Promise<void> {
|
||||
return apiRequest<void>(`/expenses/${expenseId}`, { method: "DELETE" })
|
||||
},
|
||||
|
||||
/** Accrue to GL: Debit the title's expense account / Credit Accrued Expenses. */
|
||||
post(expenseId: number): Promise<Expense> {
|
||||
return apiRequest<Expense>(`/expenses/${expenseId}/post`, { method: "POST" })
|
||||
},
|
||||
|
||||
/** Draft only; posts and pays in one transaction — full or partial (the rest stays as the balance). */
|
||||
postAndPay(expenseId: number, request: CreateExpensePaymentRequest): Promise<Expense> {
|
||||
return apiRequest<Expense>(`/expenses/${expenseId}/post-and-pay`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Draft, or Posted with no payments (posts a reversing GL entry). */
|
||||
cancel(expenseId: number, reason: string): Promise<Expense> {
|
||||
return apiRequest<Expense>(`/expenses/${expenseId}/cancel`, { method: "POST", body: { reason } })
|
||||
},
|
||||
|
||||
/** Posted only; total capped at the balance. Returns one row per payment line. */
|
||||
pay(expenseId: number, request: CreateExpensePaymentRequest): Promise<ExpensePayment[]> {
|
||||
return apiRequest<ExpensePayment[]>(`/expenses/${expenseId}/payments`, { method: "POST", body: request })
|
||||
},
|
||||
|
||||
listPayments(expenseId: number): Promise<ExpensePayment[]> {
|
||||
return apiRequest<ExpensePayment[]>(`/expenses/${expenseId}/payments`)
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
ItemImage,
|
||||
ItemListItem,
|
||||
ItemOverview,
|
||||
ItemPurchasePrice,
|
||||
ItemReorderSetting,
|
||||
ItemScanResult,
|
||||
StockNature,
|
||||
@@ -95,6 +96,11 @@ export const itemsApi = {
|
||||
return apiRequest<void>(`/items/${itemId}/status`, { method: "PATCH", body: { status } })
|
||||
},
|
||||
|
||||
/** Latest posted GRN receipts of the item (newest first) — what it was bought at. */
|
||||
purchasePrices(itemId: number, take = 5): Promise<ItemPurchasePrice[]> {
|
||||
return apiRequest<ItemPurchasePrice[]>(`/items/${itemId}/purchase-prices${buildQuery({ take })}`)
|
||||
},
|
||||
|
||||
updateReorder(itemId: number, request: UpdateItemReorderRequest): Promise<{ settings: ItemReorderSetting[] }> {
|
||||
return apiRequest<{ settings: ItemReorderSetting[] }>(`/items/${itemId}/reorder`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5).
|
||||
// Auto-posts on create with a mandatory reason code (FR-STK-07).
|
||||
// One typed client method per adjustment endpoint (docs/11-BACKEND-PHASE1.md §5.5). Creating
|
||||
// submits a request with a mandatory reason code; it moves stock once approved
|
||||
// (docs/DUAL_APPROVAL_PLAN.md §5), or at once when approvals are switched off.
|
||||
import { apiRequest, buildQuery } from "@/lib/api-client"
|
||||
import { PagedResponse } from "@/types/common"
|
||||
import { CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
|
||||
import { AdjustmentStatus, CreateAdjustmentRequest, StockAdjustment, StockAdjustmentSummary } from "@/types/stock"
|
||||
|
||||
export interface ListAdjustmentsParams {
|
||||
page?: number
|
||||
@@ -11,6 +12,7 @@ export interface ListAdjustmentsParams {
|
||||
warehouseId?: number
|
||||
branchId?: number
|
||||
reasonCodeId?: number
|
||||
status?: AdjustmentStatus
|
||||
sort?: string
|
||||
}
|
||||
|
||||
@@ -30,4 +32,18 @@ export const stockAdjustmentsApi = {
|
||||
create(request: CreateAdjustmentRequest): Promise<StockAdjustment> {
|
||||
return apiRequest<StockAdjustment>("/stock-adjustments", { method: "POST", body: request })
|
||||
},
|
||||
|
||||
/** Next approval; the final one posts (409 STOCK_NEGATIVE_BLOCKED if stock no longer allows it). */
|
||||
approve(adjustmentId: number, comment?: string): Promise<StockAdjustment> {
|
||||
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/approve`, { method: "POST", body: { comment } })
|
||||
},
|
||||
|
||||
reject(adjustmentId: number, reason: string): Promise<StockAdjustment> {
|
||||
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/reject`, { method: "POST", body: { reason } })
|
||||
},
|
||||
|
||||
/** Requester only, while pending. */
|
||||
cancel(adjustmentId: number): Promise<StockAdjustment> {
|
||||
return apiRequest<StockAdjustment>(`/stock-adjustments/${adjustmentId}/cancel`, { method: "POST" })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { reasonCodesApi } from "@/lib/api/reason-codes"
|
||||
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
|
||||
import { stockApi } from "@/lib/api/stock"
|
||||
import { StockAdjustment } from "@/types/stock"
|
||||
import { AdjustmentStatus, StockAdjustment } from "@/types/stock"
|
||||
|
||||
/**
|
||||
* Reason codes treated as "wastage" (loss-type) causes. These match the seeded codes in
|
||||
@@ -37,8 +37,10 @@ export interface WastageRecord {
|
||||
reasonCodeId: number
|
||||
/** Positive quantity wasted (the underlying adjustment line is a negative qtyDelta). */
|
||||
qty: number
|
||||
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted. */
|
||||
/** FIFO cost of the wasted quantity, summed from the ledger entries this adjustment posted (0 until posted). */
|
||||
value: number
|
||||
/** Posted, or still waiting for approval (rejected/withdrawn requests are left out). */
|
||||
status: AdjustmentStatus
|
||||
}
|
||||
|
||||
export interface ListWastageParams {
|
||||
@@ -81,7 +83,8 @@ export const wastageApi = {
|
||||
page: params.page,
|
||||
pageSize: params.pageSize ?? 50,
|
||||
})
|
||||
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId))
|
||||
// A rejected or withdrawn request never wrote anything off.
|
||||
const summaries = res.items.filter((a) => wastageIds.has(a.reasonCodeId) && a.status !== "Rejected" && a.status !== "Cancelled")
|
||||
|
||||
const detailed = await Promise.all(
|
||||
summaries.map(async (summary) => {
|
||||
@@ -107,6 +110,7 @@ export const wastageApi = {
|
||||
reasonCodeId: adjustment.reasonCodeId,
|
||||
qty: Math.abs(line.qtyDelta),
|
||||
value: Math.round(value * 100) / 100,
|
||||
status: adjustment.status,
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,18 @@ export function formatAmount(value: number | null | undefined, zeroDash = false)
|
||||
return value < 0 ? `(${formatted})` : formatted
|
||||
}
|
||||
|
||||
/** Like {@link formatAmount} but a negative keeps a leading minus instead of parentheses: `-1234.5` -> "-1,234.50". */
|
||||
export function formatSignedAmount(value: number | null | undefined): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—"
|
||||
return value < 0 ? `-${AMOUNT_FORMATTER.format(Math.abs(value))}` : AMOUNT_FORMATTER.format(value)
|
||||
}
|
||||
|
||||
/** A profit figure as words + amount: `1200` -> { label: "Profit", amount: "1,200.00" }, `-300` -> { label: "Loss", amount: "300.00" }. */
|
||||
export function profitOrLoss(value: number): { label: "Profit" | "Loss"; amount: string; isLoss: boolean } {
|
||||
const isLoss = value < 0
|
||||
return { label: isLoss ? "Loss" : "Profit", amount: formatAmount(Math.abs(value)), isLoss }
|
||||
}
|
||||
|
||||
/** `"2026-07-01"` / an ISO timestamp -> "01 Jul 2026" for report headers and tables. */
|
||||
export function formatReportDate(value: string | null | undefined): string {
|
||||
if (!value) return "—"
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
// Day-End Sales Report PDFs (docs/DAY_END_SALES_REPORT_PLAN.md §6) — built in the browser with
|
||||
// jsPDF (the backend has no PDF library). The table is drawn directly rather than through
|
||||
// jspdf-autotable, so no extra dependency; the chart is rendered on an offscreen canvas in light
|
||||
// colours, so the printout never depends on the on-screen theme.
|
||||
import type { jsPDF as JsPdf } from "jspdf"
|
||||
|
||||
import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format"
|
||||
import { maskAccountNumber } from "@/types/bank-deposits"
|
||||
import { DayEndDailyReport, DayEndReport, DayEndReportDay } from "@/types/sales"
|
||||
|
||||
const PAGE = { width: 210, height: 297, margin: 14 }
|
||||
const BOTTOM = PAGE.height - 16
|
||||
|
||||
type Column = { header: string; width: number; align?: "left" | "right" }
|
||||
|
||||
async function newDoc(): Promise<JsPdf> {
|
||||
const { jsPDF } = await import("jspdf")
|
||||
return new jsPDF({ unit: "mm", format: "a4" })
|
||||
}
|
||||
|
||||
/** Title block; returns the y where content starts. */
|
||||
function header(doc: JsPdf, title: string, subtitle: string, branchLabel: string): number {
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.setFontSize(16)
|
||||
doc.text(title, PAGE.margin, 20)
|
||||
doc.setFont("helvetica", "normal")
|
||||
doc.setFontSize(10)
|
||||
doc.setTextColor(90)
|
||||
doc.text(subtitle, PAGE.margin, 27)
|
||||
doc.text(`Branch: ${branchLabel}`, PAGE.margin, 32)
|
||||
doc.text(`Generated ${new Date().toLocaleString()}`, PAGE.width - PAGE.margin, 32, { align: "right" })
|
||||
doc.setTextColor(0)
|
||||
doc.setDrawColor(200)
|
||||
doc.line(PAGE.margin, 35, PAGE.width - PAGE.margin, 35)
|
||||
return 42
|
||||
}
|
||||
|
||||
function footer(doc: JsPdf) {
|
||||
const pages = doc.getNumberOfPages()
|
||||
for (let i = 1; i <= pages; i++) {
|
||||
doc.setPage(i)
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(120)
|
||||
doc.text(`Page ${i} of ${pages}`, PAGE.width - PAGE.margin, PAGE.height - 8, { align: "right" })
|
||||
doc.setTextColor(0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Label/value pairs in a grid of boxes. */
|
||||
function summaryGrid(doc: JsPdf, y: number, items: [string, string][], perRow = 4): number {
|
||||
const gap = 3
|
||||
const w = (PAGE.width - PAGE.margin * 2 - gap * (perRow - 1)) / perRow
|
||||
const h = 14
|
||||
items.forEach(([label, value], i) => {
|
||||
const x = PAGE.margin + (i % perRow) * (w + gap)
|
||||
const top = y + Math.floor(i / perRow) * (h + gap)
|
||||
doc.setDrawColor(215)
|
||||
doc.roundedRect(x, top, w, h, 1.5, 1.5)
|
||||
doc.setFontSize(8)
|
||||
doc.setTextColor(100)
|
||||
doc.text(label, x + 2.5, top + 5)
|
||||
doc.setFontSize(11)
|
||||
doc.setTextColor(0)
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.text(value, x + 2.5, top + 11)
|
||||
doc.setFont("helvetica", "normal")
|
||||
})
|
||||
return y + Math.ceil(items.length / perRow) * (h + gap) + 2
|
||||
}
|
||||
|
||||
function note(doc: JsPdf, y: number, text: string): number {
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(110)
|
||||
doc.text(text, PAGE.margin, y + 4)
|
||||
doc.setTextColor(0)
|
||||
return y + 8
|
||||
}
|
||||
|
||||
function sectionTitle(doc: JsPdf, y: number, text: string): number {
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.setFontSize(11)
|
||||
doc.text(text, PAGE.margin, y)
|
||||
doc.setFont("helvetica", "normal")
|
||||
return y + 4
|
||||
}
|
||||
|
||||
/** Cut `text` with "..." so it fits `maxWidth` mm at the current font. */
|
||||
function fit(doc: JsPdf, text: string, maxWidth: number): string {
|
||||
if (doc.getTextWidth(text) <= maxWidth) return text
|
||||
let cut = text
|
||||
while (cut.length > 1 && doc.getTextWidth(`${cut}...`) > maxWidth) cut = cut.slice(0, -1)
|
||||
return `${cut}...`
|
||||
}
|
||||
|
||||
/** A table that breaks across pages, repeating its header; `totals` is a bold last row. */
|
||||
function table(doc: JsPdf, y: number, columns: Column[], rows: string[][], totals?: string[]): number {
|
||||
const rowH = 6.5
|
||||
const drawHeader = (top: number) => {
|
||||
doc.setFillColor(238, 238, 236)
|
||||
doc.rect(PAGE.margin, top, PAGE.width - PAGE.margin * 2, rowH, "F")
|
||||
doc.setFont("helvetica", "bold")
|
||||
drawRow(top, columns.map((c) => c.header))
|
||||
doc.setFont("helvetica", "normal")
|
||||
return top + rowH
|
||||
}
|
||||
const drawRow = (top: number, cells: string[]) => {
|
||||
doc.setFontSize(8.5)
|
||||
let x = PAGE.margin
|
||||
columns.forEach((col, i) => {
|
||||
const text = fit(doc, cells[i] ?? "", col.width - 4)
|
||||
if (col.align === "right") doc.text(text, x + col.width - 2, top + 4.5, { align: "right" })
|
||||
else doc.text(text, x + 2, top + 4.5)
|
||||
x += col.width
|
||||
})
|
||||
}
|
||||
|
||||
let top = drawHeader(y)
|
||||
const all = totals ? [...rows, totals] : rows
|
||||
all.forEach((cells, idx) => {
|
||||
if (top + rowH > BOTTOM) {
|
||||
doc.addPage()
|
||||
top = drawHeader(20)
|
||||
}
|
||||
const isTotals = totals !== undefined && idx === all.length - 1
|
||||
if (isTotals) {
|
||||
doc.setDrawColor(150)
|
||||
doc.line(PAGE.margin, top, PAGE.width - PAGE.margin, top)
|
||||
doc.setFont("helvetica", "bold")
|
||||
} else if (idx % 2 === 1) {
|
||||
doc.setFillColor(248, 248, 246)
|
||||
doc.rect(PAGE.margin, top, PAGE.width - PAGE.margin * 2, rowH, "F")
|
||||
}
|
||||
drawRow(top, cells)
|
||||
doc.setFont("helvetica", "normal")
|
||||
top += rowH
|
||||
})
|
||||
return top + 4
|
||||
}
|
||||
|
||||
/** The income/sales/expenses line chart as a PNG, drawn offscreen in the light palette. */
|
||||
async function chartImage(days: DayEndReportDay[]): Promise<string> {
|
||||
const { Chart, CategoryScale, LinearScale, PointElement, LineElement, Legend } = await import("chart.js")
|
||||
Chart.register(CategoryScale, LinearScale, PointElement, LineElement, Legend)
|
||||
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = 1600
|
||||
canvas.height = 640
|
||||
const series = (label: string, data: number[], color: string) => ({
|
||||
label, data, borderColor: color, backgroundColor: color, borderWidth: 3,
|
||||
pointRadius: days.length > 45 ? 0 : 4, tension: 0.25, fill: false,
|
||||
})
|
||||
const chart = new Chart(canvas, {
|
||||
type: "line",
|
||||
data: {
|
||||
labels: days.map((d) => formatReportDate(d.date)),
|
||||
datasets: [
|
||||
series("Income", days.map((d) => d.income), "#2a78d6"),
|
||||
series("Sales", days.map((d) => d.totalSales), "#eb6834"),
|
||||
series("Expenses", days.map((d) => d.expenseTotal), "#1baf7a"),
|
||||
series("Profit / loss", days.map((d) => d.netProfit), "#eda100"),
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: false,
|
||||
animation: false,
|
||||
devicePixelRatio: 1,
|
||||
plugins: { legend: { position: "top", align: "start", labels: { font: { size: 22 }, usePointStyle: true } } },
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 18 }, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } },
|
||||
y: { grid: { color: "rgba(0,0,0,0.08)" }, ticks: { font: { size: 18 }, callback: (v) => formatAmount(Number(v)) } },
|
||||
},
|
||||
},
|
||||
})
|
||||
const png = chart.toBase64Image("image/png", 1)
|
||||
chart.destroy()
|
||||
return png
|
||||
}
|
||||
|
||||
const amount = (n: number) => formatAmount(n)
|
||||
|
||||
/** Income with a minus sign when negative, marked as a loss: "Loss -300.00". */
|
||||
const incomeText = (n: number) => (n < 0 ? `Loss ${formatSignedAmount(n)}` : formatSignedAmount(n))
|
||||
|
||||
/** "Profit 1,200.00" / "Loss 300.00" — a loss is labelled, not just negative. */
|
||||
const profitText = (n: number) => {
|
||||
const p = profitOrLoss(n)
|
||||
return `${p.label} ${p.amount}`
|
||||
}
|
||||
|
||||
function totalsGrid(t: DayEndReportDay): [string, string][] {
|
||||
const net = profitOrLoss(t.netProfit)
|
||||
return [
|
||||
["Total sales", amount(t.totalSales)],
|
||||
["Paid", amount(t.paidAmount)],
|
||||
["Due", amount(t.dueAmount)],
|
||||
["Expenses", amount(t.expenseTotal)],
|
||||
["Paid by cash", amount(t.paidByCash)],
|
||||
["Paid by card", amount(t.paidByCard)],
|
||||
[t.income < 0 ? "Income (loss)" : "Income", formatSignedAmount(t.income)],
|
||||
[`Net ${net.label.toLowerCase()}`, net.amount],
|
||||
]
|
||||
}
|
||||
|
||||
const DAILY_COLUMNS: Column[] = [
|
||||
{ header: "Date", width: 21 },
|
||||
{ header: "Sales", width: 20, align: "right" },
|
||||
{ header: "Paid", width: 19, align: "right" },
|
||||
{ header: "Due", width: 17, align: "right" },
|
||||
{ header: "Cash", width: 18, align: "right" },
|
||||
{ header: "Card", width: 18, align: "right" },
|
||||
{ header: "Expenses", width: 19, align: "right" },
|
||||
{ header: "Income", width: 20, align: "right" },
|
||||
{ header: "Profit / loss", width: 30, align: "right" },
|
||||
]
|
||||
|
||||
const dailyCells = (d: DayEndReportDay, label: string) => [
|
||||
label, amount(d.totalSales), amount(d.paidAmount), amount(d.dueAmount),
|
||||
amount(d.paidByCash), amount(d.paidByCard), amount(d.expenseTotal), incomeText(d.income), profitText(d.netProfit),
|
||||
]
|
||||
|
||||
const CASH_COLUMNS: Column[] = [
|
||||
{ header: "Date", width: 30 },
|
||||
{ header: "Opening cash", width: 38, align: "right" },
|
||||
{ header: "Net cash", width: 38, align: "right" },
|
||||
{ header: "Deposited to bank", width: 38, align: "right" },
|
||||
{ header: "Cash in hand", width: 38, align: "right" },
|
||||
]
|
||||
|
||||
const cashCells = (d: DayEndReportDay, label: string) => [
|
||||
label, amount(d.openingCashInHand ?? 0), amount(d.netCash), amount(d.cashDeposited ?? 0), amount(d.closingCashInHand ?? 0),
|
||||
]
|
||||
|
||||
/** Sales − cost = gross, − expenses = net profit/loss, as a right-aligned statement. */
|
||||
function profitStatement(doc: JsPdf, y: number, s: DayEndReportDay): number {
|
||||
const gross = profitOrLoss(s.grossProfit)
|
||||
const net = profitOrLoss(s.netProfit)
|
||||
const left = PAGE.width - PAGE.margin - 95
|
||||
const right = PAGE.width - PAGE.margin
|
||||
const rows: [string, string][] = [
|
||||
["Total sales", amount(s.totalSales)],
|
||||
["Less: cost of goods sold", `(${amount(s.costOfGoodsSold)})`],
|
||||
[`Gross ${gross.label.toLowerCase()}`, gross.amount],
|
||||
["Less: expenses", `(${amount(s.expenseTotal)})`],
|
||||
]
|
||||
doc.setFontSize(10)
|
||||
rows.forEach(([label, value]) => {
|
||||
doc.text(label, left, y)
|
||||
doc.text(value, right, y, { align: "right" })
|
||||
y += 6
|
||||
})
|
||||
doc.setDrawColor(150)
|
||||
doc.line(left, y - 3.5, right, y - 3.5)
|
||||
doc.setFont("helvetica", "bold")
|
||||
doc.setFontSize(12)
|
||||
if (net.isLoss) doc.setTextColor(200, 40, 40)
|
||||
else doc.setTextColor(0, 120, 0)
|
||||
doc.text(`Net ${net.label.toLowerCase()}`, left, y + 2)
|
||||
doc.text(net.amount, right, y + 2, { align: "right" })
|
||||
doc.setTextColor(0)
|
||||
doc.setFont("helvetica", "normal")
|
||||
return y + 10
|
||||
}
|
||||
|
||||
/** Day-wise income for a range: totals, the chart, then the daily table (with a totals row). */
|
||||
export async function downloadDailyIncomePdf(report: DayEndDailyReport, branchLabel: string) {
|
||||
const doc = await newDoc()
|
||||
let y = header(
|
||||
doc,
|
||||
"Day-wise Income Report",
|
||||
`${formatReportDate(report.from)} to ${formatReportDate(report.to)} (${report.days.length} days)`,
|
||||
branchLabel
|
||||
)
|
||||
y = summaryGrid(doc, y, totalsGrid(report.totals))
|
||||
|
||||
y = sectionTitle(doc, y + 2, "Daily income")
|
||||
const chartW = PAGE.width - PAGE.margin * 2
|
||||
const chartH = chartW * (640 / 1600)
|
||||
doc.addImage(await chartImage(report.days), "PNG", PAGE.margin, y, chartW, chartH)
|
||||
y += chartH + 6
|
||||
|
||||
if (y + 20 > BOTTOM) {
|
||||
doc.addPage()
|
||||
y = 20
|
||||
}
|
||||
y = sectionTitle(doc, y, "Day-wise breakdown")
|
||||
y = table(
|
||||
doc,
|
||||
y,
|
||||
DAILY_COLUMNS,
|
||||
report.days.map((d) => dailyCells(d, formatReportDate(d.date))),
|
||||
dailyCells(report.totals, "Total")
|
||||
)
|
||||
|
||||
// Its own table: the breakdown above already fills the page width.
|
||||
if (y + 20 > BOTTOM) {
|
||||
doc.addPage()
|
||||
y = 20
|
||||
}
|
||||
y = sectionTitle(doc, y + 2, "Cash in hand and bank deposits")
|
||||
const t = report.totals
|
||||
table(
|
||||
doc,
|
||||
y,
|
||||
CASH_COLUMNS,
|
||||
report.days.map((d) => cashCells(d, formatReportDate(d.date))),
|
||||
// Opening and closing are balances: the range opens with its first day and closes with its last.
|
||||
["Total", amount(t.openingCashInHand ?? 0), amount(t.netCash), amount(t.cashDeposited ?? 0), amount(t.closingCashInHand ?? 0)]
|
||||
)
|
||||
|
||||
footer(doc)
|
||||
doc.save(`daily-income_${report.from}_${report.to}.pdf`)
|
||||
}
|
||||
|
||||
/** One business day: its figures, sale counts by payment method, the sales and expense lists, then profit/loss. */
|
||||
export async function downloadDayEndReportPdf(report: DayEndReport, branchLabel: string) {
|
||||
const s = report.summary
|
||||
const sales = report.sales ?? []
|
||||
const expenses = report.expenses ?? []
|
||||
const deposits = report.deposits ?? []
|
||||
const doc = await newDoc()
|
||||
let y = header(doc, "Day-End Sales Report", formatReportDate(s.date), branchLabel)
|
||||
|
||||
y = sectionTitle(doc, y, "Sales")
|
||||
y = summaryGrid(doc, y + 1, [
|
||||
["Total sales", amount(s.totalSales)],
|
||||
["Paid amount", amount(s.paidAmount)],
|
||||
["Due amount", amount(s.dueAmount)],
|
||||
["Paid by cash", amount(s.paidByCash)],
|
||||
["Paid by card", amount(s.paidByCard)],
|
||||
["Paid other ways", amount(s.paidByOther)],
|
||||
["Earlier invoices collected", amount(s.earlierInvoiceCollections)],
|
||||
])
|
||||
|
||||
y = sectionTitle(doc, y + 2, "Cash in hand")
|
||||
y = summaryGrid(doc, y + 1, [
|
||||
["Opening cash in hand", amount(s.openingCashInHand ?? 0)],
|
||||
["Net cash today", amount(s.netCash)],
|
||||
["Deposited to bank", amount(s.cashDeposited ?? 0)],
|
||||
["Remaining cash in hand", amount(s.closingCashInHand ?? 0)],
|
||||
])
|
||||
if (deposits.length > 0) {
|
||||
y = table(
|
||||
doc,
|
||||
y,
|
||||
[
|
||||
{ header: "Deposit", width: 32 },
|
||||
{ header: "Bank account", width: 62 },
|
||||
{ header: "Slip no.", width: 30 },
|
||||
{ header: "Deposited on", width: 28 },
|
||||
{ header: "Amount", width: 30, align: "right" },
|
||||
],
|
||||
deposits.map((d) => [
|
||||
d.docNo,
|
||||
[d.bankAccountName, d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · "),
|
||||
d.depositSlipNo ?? "-",
|
||||
formatReportDate(d.depositDate),
|
||||
amount(d.amount),
|
||||
]),
|
||||
["Total", "", "", "", amount(s.cashDeposited ?? 0)]
|
||||
)
|
||||
}
|
||||
|
||||
y = sectionTitle(doc, y + 2, "Sales count")
|
||||
y = summaryGrid(doc, y + 1, [
|
||||
["All sales", String(s.salesCount)],
|
||||
["Cash sales", String(s.cashSalesCount)],
|
||||
["Card sales", String(s.cardSalesCount)],
|
||||
["Mixed / other / unpaid", `${s.mixedSalesCount} / ${s.otherSalesCount} / ${s.unpaidSalesCount}`],
|
||||
])
|
||||
|
||||
y = sectionTitle(doc, y + 2, `Sales of the day (${sales.length})`)
|
||||
if (sales.length === 0) {
|
||||
y = note(doc, y, "No posted sales on this day.")
|
||||
} else {
|
||||
y = table(
|
||||
doc,
|
||||
y,
|
||||
[
|
||||
{ header: "Time", width: 13 },
|
||||
{ header: "No.", width: 28 },
|
||||
{ header: "Customer", width: 37 },
|
||||
{ header: "Paid by", width: 20 },
|
||||
{ header: "Total", width: 22, align: "right" },
|
||||
{ header: "Due", width: 20, align: "right" },
|
||||
{ header: "Cost", width: 20, align: "right" },
|
||||
{ header: "Profit", width: 22, align: "right" },
|
||||
],
|
||||
sales.map((r) => [
|
||||
new Date(r.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
|
||||
r.docNo, r.customerName, r.paidBy, amount(r.total), amount(r.due), amount(r.cost), amount(r.grossProfit),
|
||||
]),
|
||||
["", "Total", "", "", amount(s.totalSales), amount(s.dueAmount), amount(s.costOfGoodsSold), amount(s.grossProfit)]
|
||||
)
|
||||
}
|
||||
|
||||
if (y + 20 > BOTTOM) {
|
||||
doc.addPage()
|
||||
y = 20
|
||||
}
|
||||
y = sectionTitle(doc, y + 2, `Expenses of the day (${expenses.length})`)
|
||||
if (expenses.length === 0) {
|
||||
y = note(doc, y, "No posted expenses on this day.")
|
||||
} else {
|
||||
y = table(
|
||||
doc,
|
||||
y,
|
||||
[
|
||||
{ header: "Doc no.", width: 34 },
|
||||
{ header: "Category", width: 30 },
|
||||
{ header: "Title", width: 40 },
|
||||
{ header: "Payee", width: 46 },
|
||||
{ header: "Amount", width: 32, align: "right" },
|
||||
],
|
||||
expenses.map((e) => [e.docNo, e.categoryName, e.titleName, e.payee ?? "-", amount(e.amount)]),
|
||||
["Total", "", "", "", amount(s.expenseTotal)]
|
||||
)
|
||||
}
|
||||
|
||||
if (y + 44 > BOTTOM) {
|
||||
doc.addPage()
|
||||
y = 20
|
||||
}
|
||||
y = sectionTitle(doc, y + 2, "Profit / loss")
|
||||
profitStatement(doc, y + 4, s)
|
||||
|
||||
footer(doc)
|
||||
doc.save(`day-end-report_${s.date}.pdf`)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Dual approval of stock and price adjustments (docs/DUAL_APPROVAL_PLAN.md).
|
||||
// Mirrors Backend/ERPCore/Dtos/Approvals/ApprovalDtos.cs and Dtos/Products/PriceAdjustmentDtos.cs.
|
||||
|
||||
export type ApprovalDecision = "Approved" | "Rejected"
|
||||
|
||||
/** One decision in a document's approval trail. */
|
||||
export interface ApprovalStep {
|
||||
level: number
|
||||
decision: ApprovalDecision
|
||||
decidedBy: number
|
||||
decidedByName: string | null
|
||||
decidedAt: string
|
||||
comment: string | null
|
||||
}
|
||||
|
||||
/** "ADJ" = stock adjustment, "PADJ" = price adjustment. */
|
||||
export type ApprovalDocType = "ADJ" | "PADJ"
|
||||
|
||||
export interface PendingApproval {
|
||||
docType: ApprovalDocType
|
||||
docId: number
|
||||
docNo: string
|
||||
summary: string
|
||||
/** The approval it waits for: 1, or 2 after the first. */
|
||||
level: number
|
||||
requiredApprovals: number
|
||||
requestedBy: number
|
||||
requestedByName: string | null
|
||||
requestedAt: string
|
||||
/** Whether you may give that approval; `whyNot` says why not (e.g. it's your own request). */
|
||||
canApprove: boolean
|
||||
whyNot: string | null
|
||||
}
|
||||
|
||||
export interface PendingApprovals {
|
||||
items: PendingApproval[]
|
||||
/** How many you can act on — the sidebar badge. */
|
||||
actionableCount: number
|
||||
}
|
||||
|
||||
/** Settings → Approvals. Levels: 0 = applies on create, 1 = one approver, 2 = two different approvers. */
|
||||
export interface ApprovalConfig {
|
||||
stockAdjustmentLevels: number
|
||||
/** With 2 levels: adjustments worth less than this need one approval. 0 = always two. */
|
||||
stockAdjustmentLevel2ThresholdValue: number
|
||||
priceAdjustmentLevels: number
|
||||
/** With 2 levels: price changes smaller than this % need one approval. 0 = always two. */
|
||||
priceAdjustmentLevel2ThresholdPercent: number
|
||||
/** Only these roles may give the second approval; empty = anyone with the Approve action. */
|
||||
level2RoleCodes: string[]
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export type UpdateApprovalConfigRequest = Omit<ApprovalConfig, "updatedAt">
|
||||
|
||||
// --- Price adjustments ---------------------------------------------------------------------------
|
||||
|
||||
export type PriceAdjustmentStatus = "PendingApproval" | "PendingSecondApproval" | "Approved" | "Rejected" | "Cancelled"
|
||||
|
||||
/** One item's requested change. current* differ from old* when the item was repriced since (the request is then stale). */
|
||||
export interface PriceAdjustmentLine {
|
||||
priceAdjustmentLineId: number
|
||||
itemId: number
|
||||
sku: string
|
||||
itemName: string
|
||||
oldMinPrice: number | null
|
||||
oldMaxPrice: number | null
|
||||
oldFlatPrice: number | null
|
||||
newMinPrice: number | null
|
||||
newMaxPrice: number | null
|
||||
newFlatPrice: number | null
|
||||
currentMinPrice: number | null
|
||||
currentMaxPrice: number | null
|
||||
currentFlatPrice: number | null
|
||||
}
|
||||
|
||||
export interface PriceAdjustmentSummary {
|
||||
priceAdjustmentId: number
|
||||
docNo: string
|
||||
reason: string
|
||||
status: PriceAdjustmentStatus
|
||||
requiredApprovals: number
|
||||
pendingLevel: number | null
|
||||
lineCount: number
|
||||
createdBy: number
|
||||
createdByName: string | null
|
||||
createdAt: string
|
||||
appliedAt: string | null
|
||||
}
|
||||
|
||||
export interface PriceAdjustment {
|
||||
priceAdjustmentId: number
|
||||
docNo: string
|
||||
reason: string
|
||||
status: PriceAdjustmentStatus
|
||||
requiredApprovals: number
|
||||
pendingLevel: number | null
|
||||
createdBy: number
|
||||
createdByName: string | null
|
||||
createdAt: string
|
||||
approvedLevel1By: number | null
|
||||
approvedLevel1At: string | null
|
||||
approvedLevel2By: number | null
|
||||
approvedLevel2At: string | null
|
||||
rejectedBy: number | null
|
||||
rejectedAt: string | null
|
||||
rejectReason: string | null
|
||||
cancelledAt: string | null
|
||||
appliedAt: string | null
|
||||
lines: PriceAdjustmentLine[]
|
||||
approvalHistory: ApprovalStep[]
|
||||
canApprove: boolean
|
||||
whyNot: string | null
|
||||
canWithdraw: boolean
|
||||
}
|
||||
|
||||
export interface PriceAdjustmentLineInput {
|
||||
itemId: number
|
||||
/** The prices the item should have afterwards — null clears that price. */
|
||||
newMinPrice: number | null
|
||||
newMaxPrice: number | null
|
||||
newFlatPrice: number | null
|
||||
}
|
||||
|
||||
export interface CreatePriceAdjustmentRequest {
|
||||
reason: string
|
||||
lines: PriceAdjustmentLineInput[]
|
||||
}
|
||||
|
||||
export interface ItemPriceHistory {
|
||||
itemPriceHistoryId: number
|
||||
itemId: number
|
||||
priceAdjustmentId: number | null
|
||||
docNo: string | null
|
||||
oldMinPrice: number | null
|
||||
oldMaxPrice: number | null
|
||||
oldFlatPrice: number | null
|
||||
newMinPrice: number | null
|
||||
newMaxPrice: number | null
|
||||
newFlatPrice: number | null
|
||||
requestedBy: number
|
||||
requestedByName: string | null
|
||||
approvedLevel1By: number | null
|
||||
approvedLevel1ByName: string | null
|
||||
approvedLevel2By: number | null
|
||||
approvedLevel2ByName: string | null
|
||||
appliedAt: string
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Bank deposits of till cash and the cash in hand behind them (docs/BANK_DEPOSIT_MODULE_PLAN.md).
|
||||
// Mirrors Backend/ERPCore/Dtos/Accounts/BankDepositDtos.cs.
|
||||
import { PaginationMeta } from "@/types/common"
|
||||
|
||||
export type BankDepositStatus = "Posted" | "Cancelled"
|
||||
|
||||
/**
|
||||
* One business day's till cash. cashIn = slip/bundle cash + cash invoice payments; cashOut = cash expense
|
||||
* payments; deposited = Posted deposits for that business day. closing = opening + netCash − deposited,
|
||||
* and becomes the next day's opening. Days before tracking starts have opening 0.
|
||||
*/
|
||||
export interface CashPosition {
|
||||
date: string
|
||||
opening: number
|
||||
cashIn: number
|
||||
cashOut: number
|
||||
netCash: number
|
||||
deposited: number
|
||||
closing: number
|
||||
}
|
||||
|
||||
export interface CashInHand {
|
||||
position: CashPosition
|
||||
/** Lowest closing from that day up to today — a back-dated deposit lowers every later day too. */
|
||||
availableToDeposit: number
|
||||
branchId: number | null
|
||||
trackingStartDate: string | null
|
||||
timeZone: string
|
||||
/** GL code of the cash account the deposit form preselects. */
|
||||
defaultCashAccountCode: string | null
|
||||
}
|
||||
|
||||
export interface CashInHandDaily {
|
||||
from: string
|
||||
to: string
|
||||
days: CashPosition[]
|
||||
branchId: number | null
|
||||
trackingStartDate: string | null
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
export interface BankDeposit {
|
||||
bankDepositId: number
|
||||
docNo: string
|
||||
branchId: number
|
||||
branchName: string | null
|
||||
businessDate: string
|
||||
depositDate: string
|
||||
amount: number
|
||||
fromGlCashAccountId: number
|
||||
fromCashAccountCode: string
|
||||
fromCashAccountName: string
|
||||
toGlBankAccountId: number
|
||||
toBankAccountCode: string
|
||||
toBankAccountName: string
|
||||
toBankName: string | null
|
||||
toAccountNumber: string | null
|
||||
depositSlipNo: string | null
|
||||
depositedBy: string | null
|
||||
remarks: string | null
|
||||
status: BankDepositStatus
|
||||
glJournalNo: string | null
|
||||
glPostedAt: string | null
|
||||
glReversalJournalNo: string | null
|
||||
cancelReason: string | null
|
||||
cancelledAt: string | null
|
||||
createdBy: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** A page of deposits plus the total of every row matching the filter. */
|
||||
export interface BankDepositList {
|
||||
items: BankDeposit[]
|
||||
pagination: PaginationMeta
|
||||
totalAmount: number
|
||||
}
|
||||
|
||||
export interface BankDepositListFilterParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
/** Business date range, inclusive. */
|
||||
from?: string
|
||||
to?: string
|
||||
branchId?: number
|
||||
bankAccountId?: number
|
||||
status?: BankDepositStatus
|
||||
}
|
||||
|
||||
export interface CreateBankDepositRequest {
|
||||
/** Required when branching is enabled; ignored for branch users (always their own branch). */
|
||||
branchId: number
|
||||
businessDate: string
|
||||
/** Omitted ⇒ the business date. */
|
||||
depositDate?: string
|
||||
amount: number
|
||||
fromGlCashAccountId: number
|
||||
toGlBankAccountId: number
|
||||
depositSlipNo?: string
|
||||
depositedBy?: string
|
||||
remarks?: string
|
||||
}
|
||||
|
||||
/** "0001234567" → "••••4567"; short or missing numbers pass through. */
|
||||
export function maskAccountNumber(accountNumber: string | null | undefined): string {
|
||||
if (!accountNumber) return ""
|
||||
return accountNumber.length > 4 ? `••••${accountNumber.slice(-4)}` : accountNumber
|
||||
}
|
||||
@@ -92,6 +92,9 @@ export interface BundleSale extends BundleSaleSummary, BundleSaleTotals {
|
||||
bundleSaleTemplateId: number
|
||||
updatedAt: string | null
|
||||
lines: BundleSaleLine[]
|
||||
/** cashAmount + cardAmount === grandTotal (a bundle sale is always fully paid). */
|
||||
cashAmount: number
|
||||
cardAmount: number
|
||||
}
|
||||
|
||||
export interface BundleSalePostingIssue {
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface Customer {
|
||||
status: EntityStatus
|
||||
createdAt: string
|
||||
updatedAt: string | null
|
||||
/** Unpaid balance across Posted sales invoices. Only filled by the list endpoint (0 elsewhere). */
|
||||
outstandingAmount?: number
|
||||
/** Posted sales invoices with a balance still due. Only filled by the list endpoint. */
|
||||
outstandingInvoiceCount?: number
|
||||
/** Latest invoice payment; null if never paid. Only filled by the list endpoint. */
|
||||
lastPaymentDate?: string | null
|
||||
}
|
||||
|
||||
export interface CreateCustomerRequest {
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
// Expense module types — mirror Backend/ERPCore/Dtos/Expenses/ExpenseDtos.cs
|
||||
// (docs/EXPENSE_MODULE_PLAN.md). Dates without a time (expenseDate, dueDate, from/to) are
|
||||
// `YYYY-MM-DD`; periodMonth is `YYYY-MM`.
|
||||
import { EntityStatus } from "@/types/common"
|
||||
|
||||
export type ExpenseStatus = "Draft" | "Posted" | "Cancelled"
|
||||
export type ExpensePaymentStatus = "Unpaid" | "PartiallyPaid" | "Paid"
|
||||
export type ExpensePaymentMethod = "Cash" | "BankTransfer" | "Cheque" | "Card"
|
||||
|
||||
export const expensePaymentMethodLabels: Record<ExpensePaymentMethod, string> = {
|
||||
Cash: "Cash",
|
||||
BankTransfer: "Bank transfer",
|
||||
Cheque: "Cheque",
|
||||
Card: "Card",
|
||||
}
|
||||
|
||||
export interface ExpenseTitle {
|
||||
expenseTitleId: number
|
||||
expenseCategoryId: number
|
||||
name: string
|
||||
glExpenseAccountCode: string
|
||||
/** Seeded rows keep their name; they can only be deactivated (and have their GL code corrected). */
|
||||
isSystem: boolean
|
||||
status: EntityStatus
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface ExpenseCategory {
|
||||
expenseCategoryId: number
|
||||
code: string
|
||||
name: string
|
||||
isSystem: boolean
|
||||
status: EntityStatus
|
||||
sortOrder: number
|
||||
titles: ExpenseTitle[]
|
||||
}
|
||||
|
||||
export interface UpdateExpenseCategoryRequest {
|
||||
name: string
|
||||
status: EntityStatus
|
||||
}
|
||||
|
||||
export interface CreateExpenseTitleRequest {
|
||||
name: string
|
||||
glExpenseAccountCode: string
|
||||
}
|
||||
|
||||
export interface UpdateExpenseTitleRequest {
|
||||
name: string
|
||||
glExpenseAccountCode: string
|
||||
status: EntityStatus
|
||||
}
|
||||
|
||||
export interface ExpenseSummary {
|
||||
expenseId: number
|
||||
docNo: string
|
||||
branchId: number
|
||||
expenseDate: string
|
||||
dueDate: string | null
|
||||
expenseCategoryId: number
|
||||
categoryName: string
|
||||
expenseTitleId: number
|
||||
titleName: string
|
||||
payee: string | null
|
||||
billReference: string | null
|
||||
periodMonth: string | null
|
||||
amount: number
|
||||
paidAmount: number
|
||||
balanceAmount: number
|
||||
status: ExpenseStatus
|
||||
paymentStatus: ExpensePaymentStatus
|
||||
glJournalNo: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ExpensePayment {
|
||||
expensePaymentId: number
|
||||
expenseId: number
|
||||
amount: number
|
||||
paymentDate: string
|
||||
paymentMethod: ExpensePaymentMethod
|
||||
glBankAccountId: number
|
||||
bankAccountName: string
|
||||
chequeNo: string | null
|
||||
reference: string | null
|
||||
/** Shared by every line of one payment submission (one GL entry per submission). */
|
||||
glJournalNo: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface Expense extends ExpenseSummary {
|
||||
vendorId: number | null
|
||||
vendorName: string | null
|
||||
description: string | null
|
||||
glExpenseAccountCode: string | null
|
||||
glPostedAt: string | null
|
||||
glReversalJournalNo: string | null
|
||||
cancelReason: string | null
|
||||
createdBy: number
|
||||
updatedAt: string | null
|
||||
payments: ExpensePayment[]
|
||||
}
|
||||
|
||||
export interface CreateExpenseRequest {
|
||||
/** 0 ⇒ the server resolves the default branch. */
|
||||
branchId: number
|
||||
expenseDate: string
|
||||
dueDate?: string | null
|
||||
expenseCategoryId: number
|
||||
expenseTitleId: number
|
||||
payee?: string | null
|
||||
vendorId?: number | null
|
||||
billReference?: string | null
|
||||
description?: string | null
|
||||
periodMonth?: string | null
|
||||
amount: number
|
||||
}
|
||||
|
||||
export type UpdateExpenseRequest = CreateExpenseRequest
|
||||
|
||||
export interface ExpensePaymentLineRequest {
|
||||
glBankAccountId: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface CreateExpensePaymentRequest {
|
||||
lines: ExpensePaymentLineRequest[]
|
||||
paymentMethod: ExpensePaymentMethod
|
||||
/** Required when paymentMethod is Cheque. */
|
||||
chequeNo?: string | null
|
||||
reference?: string | null
|
||||
/** `YYYY-MM-DD`; omitted ⇒ today; never future. */
|
||||
paymentDate?: string
|
||||
}
|
||||
|
||||
export interface ExpenseListFilterParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
q?: string
|
||||
status?: ExpenseStatus
|
||||
paymentStatus?: ExpensePaymentStatus
|
||||
categoryId?: number
|
||||
titleId?: number
|
||||
from?: string
|
||||
to?: string
|
||||
overdueOnly?: boolean
|
||||
}
|
||||
|
||||
export interface ExpenseTitleTotal {
|
||||
expenseTitleId: number
|
||||
titleName: string
|
||||
amount: number
|
||||
count: number
|
||||
}
|
||||
|
||||
export interface ExpenseCategoryTotal {
|
||||
expenseCategoryId: number
|
||||
categoryName: string
|
||||
amount: number
|
||||
count: number
|
||||
titles: ExpenseTitleTotal[]
|
||||
}
|
||||
|
||||
export interface ExpenseSummaryReport {
|
||||
from: string
|
||||
to: string
|
||||
totalAmount: number
|
||||
paidAmount: number
|
||||
unpaidAmount: number
|
||||
count: number
|
||||
/** All-time unpaid balance of Posted expenses (not limited to from/to). */
|
||||
outstandingAmount: number
|
||||
overdueAmount: number
|
||||
overdueCount: number
|
||||
draftCount: number
|
||||
categories: ExpenseCategoryTotal[]
|
||||
}
|
||||
@@ -82,6 +82,21 @@ export interface ItemOverview {
|
||||
salesQtyLast30Days: number
|
||||
}
|
||||
|
||||
/** One posted GRN receipt of an item. `netUnitCost` = unit cost after trade discount, VAT excluded — the stock cost. */
|
||||
export interface ItemPurchasePrice {
|
||||
grnId: number
|
||||
grnDocNo: string
|
||||
receivedAt: string
|
||||
vendorId: number
|
||||
vendorName: string | null
|
||||
warehouseCode: string | null
|
||||
qty: number
|
||||
unitCost: number
|
||||
discountPct: number
|
||||
netUnitCost: number
|
||||
vatPct: number
|
||||
}
|
||||
|
||||
export interface ItemReorderSetting {
|
||||
warehouseId: number
|
||||
reorderPoint: number
|
||||
|
||||
@@ -167,11 +167,24 @@ export interface SalesInvoicePayment {
|
||||
reference: string | null
|
||||
glJournalNo: string | null
|
||||
createdAt: string
|
||||
paymentMethod: SalesPaymentMethod
|
||||
}
|
||||
|
||||
/** How a customer paid — drives the Day-End Report's cash/card split. */
|
||||
export type SalesPaymentMethod = "Cash" | "Card" | "BankTransfer" | "Cheque"
|
||||
|
||||
export const salesPaymentMethodLabels: Record<SalesPaymentMethod, string> = {
|
||||
Cash: "Cash",
|
||||
Card: "Card",
|
||||
BankTransfer: "Bank transfer",
|
||||
Cheque: "Cheque",
|
||||
}
|
||||
|
||||
export interface CreateSalesInvoicePaymentRequest {
|
||||
amount: number
|
||||
glBankAccountId: number
|
||||
/** Omitted ⇒ the backend derives it from the account (Cash account ⇒ Cash, Bank ⇒ Card). */
|
||||
paymentMethod?: SalesPaymentMethod
|
||||
reference?: string | null
|
||||
/** `YYYY-MM-DD`; when the customer paid (also the GL entry date). Omitted ⇒ today; never future. */
|
||||
paymentDate?: string
|
||||
@@ -180,7 +193,7 @@ export interface CreateSalesInvoicePaymentRequest {
|
||||
/** Pay several Posted invoices of one (non-cash) customer at once; applied oldest invoice first. */
|
||||
export interface CreateBulkSalesInvoicePaymentRequest {
|
||||
salesInvoiceIds: number[]
|
||||
lines: { amount: number; glBankAccountId: number }[]
|
||||
lines: { amount: number; glBankAccountId: number; paymentMethod?: SalesPaymentMethod }[]
|
||||
reference?: string | null
|
||||
/** `YYYY-MM-DD`; omitted ⇒ today; never future. */
|
||||
paymentDate?: string
|
||||
@@ -250,6 +263,9 @@ export interface SalesSlipTotals {
|
||||
grandTotal: number
|
||||
paidAmount: number
|
||||
balanceAmount: number
|
||||
/** cashAmount + cardAmount === paidAmount. */
|
||||
cashAmount: number
|
||||
cardAmount: number
|
||||
}
|
||||
|
||||
export interface SalesSlipSummary {
|
||||
@@ -308,6 +324,8 @@ export interface CreateSalesSlipRequest {
|
||||
lines: CreateSalesSlipLineRequest[]
|
||||
/** Omit for the default: fully paid (paidAmount === grandTotal, balanceAmount === 0). */
|
||||
paidAmount?: number | null
|
||||
/** The part of paidAmount tendered by card (the rest is cash). Omit for all cash. */
|
||||
cardAmount?: number | null
|
||||
}
|
||||
|
||||
export type UpdateSalesSlipRequest = CreateSalesSlipRequest
|
||||
@@ -595,3 +613,97 @@ export interface CreateSalesDayEndRequest {
|
||||
cashierUserId: number
|
||||
businessDate?: string | null
|
||||
}
|
||||
|
||||
// Day-End Sales Report (docs/DAY_END_SALES_REPORT_PLAN.md) ---------------------------------------
|
||||
|
||||
/** One business day's figures — or, with `date` null, a range's totals. Income = totalSales − expenseTotal. */
|
||||
export interface DayEndReportDay {
|
||||
date: string | null
|
||||
totalSales: number
|
||||
paidAmount: number
|
||||
dueAmount: number
|
||||
paidByCash: number
|
||||
paidByCard: number
|
||||
/** Bank transfer + cheque. */
|
||||
paidByOther: number
|
||||
salesCount: number
|
||||
cashSalesCount: number
|
||||
cardSalesCount: number
|
||||
/** Paid more than one way (e.g. part cash, part card). */
|
||||
mixedSalesCount: number
|
||||
otherSalesCount: number
|
||||
unpaidSalesCount: number
|
||||
expenseTotal: number
|
||||
income: number
|
||||
/** FIFO cost of the stock the day's sales issued. */
|
||||
costOfGoodsSold: number
|
||||
/** totalSales − costOfGoodsSold. */
|
||||
grossProfit: number
|
||||
/** grossProfit − expenseTotal; negative is a loss. */
|
||||
netProfit: number
|
||||
/** Payments received this day on invoices from earlier days — not part of paidAmount. */
|
||||
earlierInvoiceCollections: number
|
||||
/** Cash in this day minus expenses paid in cash this day — a till check. */
|
||||
netCash: number
|
||||
/** Cash left in the till from earlier days (Bank Deposit module). For a range's totals, the first day's. */
|
||||
openingCashInHand: number
|
||||
/** Posted bank deposits made against this business day. */
|
||||
cashDeposited: number
|
||||
/** openingCashInHand + netCash − cashDeposited: still in hand. For a range's totals, the last day's. */
|
||||
closingCashInHand: number
|
||||
}
|
||||
|
||||
/** A Posted bank deposit of the day's cash. */
|
||||
export interface DayEndReportDeposit {
|
||||
bankDepositId: number
|
||||
docNo: string
|
||||
bankAccountName: string
|
||||
bankName: string | null
|
||||
accountNumber: string | null
|
||||
depositSlipNo: string | null
|
||||
depositDate: string
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface DayEndReportExpense {
|
||||
expenseId: number
|
||||
docNo: string
|
||||
categoryName: string
|
||||
titleName: string
|
||||
payee: string | null
|
||||
amount: number
|
||||
paidAmount: number
|
||||
}
|
||||
|
||||
export interface DayEndReportSale {
|
||||
docType: "Invoice" | "Slip" | "Bundle"
|
||||
docId: number
|
||||
docNo: string
|
||||
at: string
|
||||
customerName: string
|
||||
total: number
|
||||
paid: number
|
||||
due: number
|
||||
/** Cash, Card, Bank transfer, Cheque, Mixed or Unpaid. */
|
||||
paidBy: string
|
||||
cost: number
|
||||
grossProfit: number
|
||||
}
|
||||
|
||||
export interface DayEndReport {
|
||||
summary: DayEndReportDay
|
||||
sales: DayEndReportSale[]
|
||||
expenses: DayEndReportExpense[]
|
||||
deposits: DayEndReportDeposit[]
|
||||
branchId: number | null
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
export interface DayEndDailyReport {
|
||||
from: string
|
||||
to: string
|
||||
days: DayEndReportDay[]
|
||||
totals: DayEndReportDay
|
||||
branchId: number | null
|
||||
timeZone: string
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Stock Management types (docs/11-BACKEND-PHASE1.md §5; FR-STK-01..14, FR-WH-*).
|
||||
// Mirrors the live backend DTOs. The list endpoints (GET /stock-transfers,
|
||||
// /stock-adjustments, /stock-counts, /stock/on-hand/list) all exist as of 2026-07-16.
|
||||
import { ApprovalStep } from "@/types/approvals"
|
||||
import { Requisition } from "@/types/procurement"
|
||||
|
||||
export type Currency = "LKR"
|
||||
@@ -165,9 +166,12 @@ export interface ReceiveTransferResponse {
|
||||
|
||||
// --- 5.5 Adjustments ------------------------------------------------------------------
|
||||
|
||||
/** Phase 1 auto-posts, so `Posted` is what you see; the other two exist for the deferred
|
||||
* approval workflow (FR-STK-07) and are in the server enum. */
|
||||
export type AdjustmentStatus = "Draft" | "PendingApproval" | "Posted"
|
||||
/**
|
||||
* An adjustment is a request until approved (docs/DUAL_APPROVAL_PLAN.md §5): PendingApproval →
|
||||
* (PendingSecondApproval →) Posted, or Rejected / Cancelled. It moves stock only when Posted.
|
||||
* With approvals switched off it is Posted straight away.
|
||||
*/
|
||||
export type AdjustmentStatus = "Draft" | "PendingApproval" | "PendingSecondApproval" | "Posted" | "Rejected" | "Cancelled"
|
||||
|
||||
export interface AdjustmentLine {
|
||||
adjLineId: number
|
||||
@@ -177,6 +181,9 @@ export interface AdjustmentLine {
|
||||
/** Signed: negative FIFO-consumes, positive creates a layer. */
|
||||
qtyDelta: number
|
||||
serialId: number | null
|
||||
/** Serials named on the request (shown while it waits for approval). */
|
||||
requestedSerialIds?: number[] | null
|
||||
requestedSerialNos?: string[] | null
|
||||
}
|
||||
|
||||
export interface StockAdjustment {
|
||||
@@ -191,6 +198,23 @@ export interface StockAdjustment {
|
||||
glPostedAt: string | null
|
||||
lines: AdjustmentLine[]
|
||||
ledgerRefs: number[]
|
||||
/** 0 = posted without approval, 1 or 2. */
|
||||
requiredApprovals: number
|
||||
/** The approval it waits for (1 or 2); null once decided. */
|
||||
pendingLevel: number | null
|
||||
approvedLevel1By: number | null
|
||||
approvedLevel1At: string | null
|
||||
approvedLevel2By: number | null
|
||||
approvedLevel2At: string | null
|
||||
rejectedBy: number | null
|
||||
rejectedAt: string | null
|
||||
rejectReason: string | null
|
||||
postedAt: string | null
|
||||
approvalHistory: ApprovalStep[] | null
|
||||
canApprove: boolean
|
||||
whyNot: string | null
|
||||
canWithdraw: boolean
|
||||
createdByName: string | null
|
||||
}
|
||||
|
||||
export interface StockAdjustmentSummary {
|
||||
@@ -203,6 +227,8 @@ export interface StockAdjustmentSummary {
|
||||
createdAt: string
|
||||
lineCount: number
|
||||
glJournalNo: string | null
|
||||
requiredApprovals: number
|
||||
pendingLevel: number | null
|
||||
}
|
||||
|
||||
export interface CreateAdjustmentLineInput {
|
||||
@@ -287,9 +313,12 @@ export interface PostCountResponse {
|
||||
status: CountStatus
|
||||
/** Null when the count had no variance to post. */
|
||||
adjustmentId: number | null
|
||||
/** Null when there was no variance, or the variance's value was 0 either way. */
|
||||
/** Null when there was no variance, or the variance's value was 0 either way — and while it waits for approval. */
|
||||
glJournalNo: string | null
|
||||
ledgerRefs: number[]
|
||||
/** The variance adjustment's status: PendingApproval when it must be approved before stock moves. */
|
||||
adjustmentStatus?: AdjustmentStatus | null
|
||||
adjustmentDocNo?: string | null
|
||||
}
|
||||
|
||||
// --- 5.7 Reorder alerts ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user