price adjustment and stock adjustments

This commit is contained in:
2026-10-12 14:08:23 +05:30
committed by ImanThiyanga
parent a788349138
commit d0354ed169
166 changed files with 98818 additions and 252 deletions
@@ -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>