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