sales & grn fixers
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
"use client"
|
||||
|
||||
import { Plus, Trash2 } from "lucide-react"
|
||||
|
||||
import { formatAmount } from "@/lib/format"
|
||||
import { CashAndBankAccountDto } from "@/types/general-ledger"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
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 }
|
||||
|
||||
export const round2 = (n: number) => Math.round(n * 100) / 100
|
||||
|
||||
/** Today's date in the user's own timezone, as `YYYY-MM-DD` (what `<input type="date">` uses). */
|
||||
export function localToday(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
export function paymentLinesTotal(lines: PaymentLine[]): number {
|
||||
return round2(lines.reduce((sum, l) => sum + (Number(l.amount) || 0), 0))
|
||||
}
|
||||
|
||||
/** Per-line errors (`account-<key>` / `amount-<key>`) plus `lines` for the total, keyed for the editor. */
|
||||
export function validatePaymentLines(lines: PaymentLine[], balance: number): Record<string, string> {
|
||||
const errors: Record<string, string> = {}
|
||||
lines.forEach((l) => {
|
||||
const amount = Number(l.amount)
|
||||
if (!l.glBankAccountId) errors[`account-${l.key}`] = "Select a payment type"
|
||||
if (!l.amount || Number.isNaN(amount) || amount <= 0) errors[`amount-${l.key}`] = "Enter a valid amount"
|
||||
})
|
||||
const total = paymentLinesTotal(lines)
|
||||
if (lines.length === 0) errors.lines = "Add at least one payment line"
|
||||
else if (total > balance) errors.lines = `Total ${formatAmount(total)} exceeds the balance (${formatAmount(balance)})`
|
||||
return errors
|
||||
}
|
||||
|
||||
interface PaymentLinesEditorProps {
|
||||
lines: PaymentLine[]
|
||||
onChange: (lines: PaymentLine[]) => void
|
||||
accounts: CashAndBankAccountDto[] | null
|
||||
/** What is owed — the fixed "Total"; Paid is the lines' sum and Remaining the difference. */
|
||||
balance: number
|
||||
errors: Record<string, string>
|
||||
}
|
||||
|
||||
/** Payment type + paid amount rows (add/remove), with a Total / Paid amount / Remaining summary. */
|
||||
export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors }: PaymentLinesEditorProps) {
|
||||
const paid = paymentLinesTotal(lines)
|
||||
const remaining = round2(balance - paid)
|
||||
|
||||
function updateLine(key: number, patch: Partial<PaymentLine>) {
|
||||
onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l)))
|
||||
}
|
||||
|
||||
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
|
||||
onChange([...lines, { key, glBankAccountId: "", amount: remaining > 0 ? remaining.toFixed(2) : "" }])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-[1fr_8rem_8rem] gap-2 text-sm font-medium">
|
||||
<span>Payment type</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>
|
||||
<Select<string> value={line.glBankAccountId} onValueChange={(v) => updateLine(line.key, { glBankAccountId: v ?? "" })}>
|
||||
<SelectTrigger
|
||||
className="w-full text-base"
|
||||
aria-label={`Payment type, line ${idx + 1}`}
|
||||
aria-invalid={!!errors[`account-${line.key}`]}
|
||||
>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select cash/bank"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(accounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName} ({a.accountType})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors[`account-${line.key}`] ? { message: errors[`account-${line.key}`] } : undefined]} />
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={line.amount}
|
||||
onChange={(e) => updateLine(line.key, { amount: e.target.value })}
|
||||
aria-label={`Paid amount, line ${idx + 1}`}
|
||||
aria-invalid={!!errors[`amount-${line.key}`]}
|
||||
className="text-right tabular-nums"
|
||||
/>
|
||||
<FieldError errors={[errors[`amount-${line.key}`] ? { message: errors[`amount-${line.key}`] } : undefined]} />
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onChange(lines.filter((l) => l.key !== line.key))}
|
||||
disabled={lines.length === 1}
|
||||
aria-label={`Remove line ${idx + 1}`}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
{/* Add sits on the last line itself, so a new split appears right below it. */}
|
||||
{idx === lines.length - 1 && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={addLine} className="mt-1 whitespace-nowrap">
|
||||
<Plus className="size-4" />
|
||||
Add line
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Total is what's owed (fixed); Paid is what the lines add up to. */}
|
||||
<div className="ml-auto grid w-full max-w-64 grid-cols-[1fr_auto] gap-x-4 gap-y-1 border-t pt-2 text-sm">
|
||||
<span className="text-muted-foreground">Total</span>
|
||||
<span className="text-right font-semibold tabular-nums">{formatAmount(balance)}</span>
|
||||
<span className="text-muted-foreground">Paid amount</span>
|
||||
<span className="text-right tabular-nums">{formatAmount(paid)}</span>
|
||||
<span className="font-medium text-destructive">{remaining < 0 ? "Over by" : "Remaining"}</span>
|
||||
<span className="text-right font-semibold text-destructive tabular-nums">{formatAmount(Math.abs(remaining))}</span>
|
||||
</div>
|
||||
<FieldError errors={[errors.lines ? { message: errors.lines } : undefined]} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
|
||||
import { bankAccountsApi } from "@/lib/api/general-ledger"
|
||||
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 { 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,
|
||||
paymentLinesTotal,
|
||||
round2,
|
||||
validatePaymentLines,
|
||||
} from "@/components/sales/PaymentLinesEditor"
|
||||
|
||||
interface SalesInvoiceBulkPaymentDialogProps {
|
||||
/** Draft or Posted invoices with a balance, all for the same (non-cash) customer. */
|
||||
invoices: SalesInvoiceSummary[]
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** A selected draft was confirmed (posted) on the way to paying it. */
|
||||
onConfirmed: (salesInvoiceId: number) => void
|
||||
/** Called with every payment recorded — one per invoice/payment-type split. */
|
||||
onPaid: (payments: SalesInvoicePayment[]) => void
|
||||
}
|
||||
|
||||
/** Oldest invoice first — the same order the backend applies the payment in. */
|
||||
function byAge(a: SalesInvoiceSummary, b: SalesInvoiceSummary) {
|
||||
return a.invoiceDate.localeCompare(b.invoiceDate) || a.salesInvoiceId - b.salesInvoiceId
|
||||
}
|
||||
|
||||
/** Pay several invoices of one customer at once, split across payment lines. The lines' total is
|
||||
* applied oldest invoice first (shown per invoice before saving) and posted server-side as a single
|
||||
* GL journal entry, so it either all goes through or none of it does. */
|
||||
export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, onConfirmed, onPaid }: SalesInvoiceBulkPaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [lines, setLines] = useState<PaymentLine[]>([])
|
||||
const [reference, setReference] = useState("")
|
||||
const [paymentDate, setPaymentDate] = useState(localToday)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// Drafts this dialog has already confirmed — so a retry after a failed payment doesn't re-post them.
|
||||
const [postedIds, setPostedIds] = useState<Set<number>>(new Set())
|
||||
|
||||
const ordered = useMemo(() => [...invoices].sort(byAge), [invoices])
|
||||
const balance = round2(ordered.reduce((sum, i) => sum + i.totals.balanceAmount, 0))
|
||||
|
||||
// Fresh form for each open / different selection — "adjust state while rendering", not an effect.
|
||||
const openKey = open && ordered.length > 0 ? ordered.map((i) => i.salesInvoiceId).join(",") : null
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setLines([{ key: 0, glBankAccountId: "", amount: balance.toFixed(2) }])
|
||||
setReference("")
|
||||
setPaymentDate(localToday())
|
||||
setErrors({})
|
||||
setPostedIds(new Set())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || accounts !== null) return
|
||||
bankAccountsApi.list("Both").then(setAccounts).catch(() => setAccounts([]))
|
||||
}, [open, accounts])
|
||||
|
||||
// Preview of how the paid amount lands on each invoice, oldest first.
|
||||
const allocation = useMemo(() => {
|
||||
let left = Math.max(0, Math.min(paymentLinesTotal(lines), balance))
|
||||
const rows: { inv: SalesInvoiceSummary; applied: number }[] = []
|
||||
for (const inv of ordered) {
|
||||
const applied = round2(Math.min(left, inv.totals.balanceAmount))
|
||||
left = round2(left - applied)
|
||||
rows.push({ inv, applied })
|
||||
}
|
||||
return rows
|
||||
}, [ordered, lines, balance])
|
||||
|
||||
if (ordered.length === 0) return null
|
||||
const customerName = ordered[0].customerSnapshotName
|
||||
const draftCount = ordered.filter((i) => i.status === "Draft" && !postedIds.has(i.salesInvoiceId)).length
|
||||
|
||||
async function submit() {
|
||||
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"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
// Drafts are confirmed first (payments only go against approved invoices) — the same
|
||||
// "confirm & pay" the single-invoice dialog does. A failed confirm stops before any payment;
|
||||
// drafts already confirmed by then simply stay approved.
|
||||
for (const inv of ordered.filter((i) => i.status === "Draft" && !postedIds.has(i.salesInvoiceId))) {
|
||||
try {
|
||||
await salesApi.postInvoice(inv.salesInvoiceId)
|
||||
setPostedIds((prev) => new Set(prev).add(inv.salesInvoiceId))
|
||||
onConfirmed(inv.salesInvoiceId)
|
||||
} catch (err) {
|
||||
toast.error(`Could not confirm invoice ${inv.invoiceNo}`, errorMessage(err))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payments = await salesApi.payInvoicesBulk({
|
||||
salesInvoiceIds: ordered.map((i) => i.salesInvoiceId),
|
||||
lines: lines.map((l) => ({ amount: Number(l.amount), glBankAccountId: Number(l.glBankAccountId) })),
|
||||
reference: reference || undefined,
|
||||
paymentDate,
|
||||
})
|
||||
const paid = round2(payments.reduce((sum, p) => sum + p.amount, 0))
|
||||
const invoiceCount = new Set(payments.map((p) => p.salesInvoiceId)).size
|
||||
toast.success(
|
||||
"Payment recorded",
|
||||
`${formatAmount(paid)} applied to ${invoiceCount} invoice${invoiceCount === 1 ? "" : "s"} (${payments[0]?.glJournalNo ?? "—"}).`
|
||||
)
|
||||
onPaid(payments)
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error("Could not record payment", errorMessage(err))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Pay {ordered.length} invoices — {customerName}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{draftCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "}
|
||||
{draftCount === 1 ? "it" : "them"} first, then record the payment.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50 text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium">Invoice</th>
|
||||
<th className="px-3 py-2 text-left font-medium">Date</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Balance</th>
|
||||
<th className="px-3 py-2 text-right font-medium">Applied</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allocation.map(({ inv, applied }) => (
|
||||
<tr key={inv.salesInvoiceId} className="border-t">
|
||||
<td className="px-3 py-2 font-medium">{inv.invoiceNo}</td>
|
||||
<td className="px-3 py-2">{new Date(inv.invoiceDate).toLocaleDateString()}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{formatAmount(inv.totals.balanceAmount)}</td>
|
||||
<td className="px-3 py-2 text-right tabular-nums">{applied > 0 ? formatAmount(applied) : "—"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bulk-pay-customer">Customer</FieldLabel>
|
||||
<Input
|
||||
id="bulk-pay-customer"
|
||||
value={customerName}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.paymentDate}>
|
||||
<FieldLabel htmlFor="bulk-pay-date">Payment date</FieldLabel>
|
||||
<Input
|
||||
id="bulk-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>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="bulk-pay-reference">Reference (optional)</FieldLabel>
|
||||
<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} />
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting ? "Recording…" : draftCount > 0 ? "Confirm & record payment" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -13,8 +13,8 @@ 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 SalesInvoicePaymentDialogProps {
|
||||
// SalesInvoiceSummary rather than the full SalesInvoice — every field this dialog touches
|
||||
@@ -24,23 +24,28 @@ interface SalesInvoicePaymentDialogProps {
|
||||
invoice: SalesInvoiceSummary | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether
|
||||
* it started that way or this dialog auto-posted it first. */
|
||||
/** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever
|
||||
* succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */
|
||||
onPaid: (payment: SalesInvoicePayment, newStatus: SalesInvoiceStatus) => void
|
||||
}
|
||||
|
||||
/** Record a customer payment against an invoice's balance — full or partial, into an existing GL
|
||||
* cash/bank account. A Draft invoice is posted first (the backend only accepts payments against a
|
||||
* Posted invoice) so this doubles as "confirm and pay in one step" from a list row; an already-Posted
|
||||
* invoice skips straight to payment. "Pick an account, submit" shape, mirrored by
|
||||
* PurchaseInvoicePaymentDialog on the AP side. */
|
||||
/** Record a customer payment against an invoice's balance — full or partial, split across one or
|
||||
* more payment lines (e.g. part cash, part bank), each into an existing GL cash/bank account. Each
|
||||
* line is posted as its own payment (the backend already allows several installments). A Draft
|
||||
* invoice is posted first (the backend only accepts payments against a Posted invoice) so this
|
||||
* doubles as "confirm and pay in one step" from a list row. If a line fails part-way, the lines
|
||||
* already recorded are removed from the form and the rest stay for a retry. */
|
||||
export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: SalesInvoicePaymentDialogProps) {
|
||||
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
|
||||
const [glBankAccountId, setGlBankAccountId] = useState("")
|
||||
const [amount, setAmount] = useState("")
|
||||
const [lines, setLines] = useState<PaymentLine[]>([])
|
||||
const [reference, setReference] = useState("")
|
||||
const [paymentDate, setPaymentDate] = useState(localToday)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// What this dialog has already done to the invoice in this session — the `invoice` prop isn't
|
||||
// guaranteed to be refreshed by the parent between a partial failure and the retry.
|
||||
const [postedHere, setPostedHere] = useState(false)
|
||||
const [paidHere, setPaidHere] = useState(0)
|
||||
|
||||
// Reset the form fields for a fresh open (or a different invoice) during render — same
|
||||
// "adjust state while rendering" pattern, not inside an effect.
|
||||
@@ -48,10 +53,12 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
const [resetFor, setResetFor] = useState<string | null>(null)
|
||||
if (openKey !== null && resetFor !== openKey) {
|
||||
setResetFor(openKey)
|
||||
setAmount(invoice!.totals.balanceAmount.toFixed(2))
|
||||
setGlBankAccountId("")
|
||||
setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }])
|
||||
setReference("")
|
||||
setPaymentDate(localToday())
|
||||
setErrors({})
|
||||
setPostedHere(false)
|
||||
setPaidHere(0)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -61,98 +68,118 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
|
||||
if (!invoice) return null
|
||||
|
||||
const isDraft = invoice.status === "Draft" && !postedHere
|
||||
const balance = round2(invoice.totals.balanceAmount - paidHere)
|
||||
|
||||
async function submit() {
|
||||
if (!invoice) return
|
||||
const nextErrors: Record<string, string> = {}
|
||||
const amountNum = Number(amount)
|
||||
if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay into"
|
||||
if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount"
|
||||
else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})`
|
||||
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"
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length > 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (invoice.status === "Draft") {
|
||||
try {
|
||||
await salesApi.postInvoice(invoice.salesInvoiceId)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
return
|
||||
}
|
||||
if (isDraft) {
|
||||
try {
|
||||
await salesApi.postInvoice(invoice.salesInvoiceId)
|
||||
setPostedHere(true)
|
||||
} catch (err) {
|
||||
toast.error("Could not confirm invoice", errorMessage(err))
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||
amount: amountNum,
|
||||
glBankAccountId: Number(glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
})
|
||||
const fullyPaid = amountNum >= invoice.totals.balanceAmount
|
||||
// One payment per line, in order. On a failure, stop: drop the recorded lines from the form and
|
||||
// keep the rest so the user can fix and retry without double-paying.
|
||||
const recorded: SalesInvoicePayment[] = []
|
||||
let failure: unknown = null
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const payment = await salesApi.payInvoice(invoice.salesInvoiceId, {
|
||||
amount: Number(line.amount),
|
||||
glBankAccountId: Number(line.glBankAccountId),
|
||||
reference: reference || undefined,
|
||||
paymentDate,
|
||||
})
|
||||
recorded.push(payment)
|
||||
onPaid(payment, "Posted")
|
||||
} catch (err) {
|
||||
failure = err
|
||||
break
|
||||
}
|
||||
}
|
||||
setSubmitting(false)
|
||||
|
||||
const recordedTotal = round2(recorded.reduce((sum, p) => sum + p.amount, 0))
|
||||
if (failure === null) {
|
||||
const journals = recorded.map((p) => p.glJournalNo ?? "—").join(", ")
|
||||
toast.success(
|
||||
invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded",
|
||||
`${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.`
|
||||
`${formatAmount(recordedTotal)} posted to the ledger (${journals}) — ${recordedTotal >= balance ? "fully paid" : "partially paid"}.`
|
||||
)
|
||||
onPaid(payment, "Posted")
|
||||
onOpenChange(false)
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment",
|
||||
errorMessage(err)
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
setPaidHere((p) => round2(p + recordedTotal))
|
||||
setLines((prev) => prev.slice(recorded.length))
|
||||
toast.error(
|
||||
recorded.length > 0
|
||||
? `${recorded.length} of ${lines.length} payment lines recorded (${formatAmount(recordedTotal)}); the rest could not be`
|
||||
: invoice.status === "Draft"
|
||||
? "Invoice was confirmed, but the payment could not be recorded"
|
||||
: "Could not record payment",
|
||||
errorMessage(failure)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
<DialogTitle>{isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(invoice.totals.balanceAmount)}</span>
|
||||
{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "}
|
||||
Balance due: <span className="font-medium text-foreground tabular-nums">{formatAmount(balance)}</span>
|
||||
</p>
|
||||
|
||||
<FieldGroup>
|
||||
<Field data-invalid={!!errors.glBankAccountId}>
|
||||
<FieldLabel htmlFor="inv-pay-account">Pay into</FieldLabel>
|
||||
<Select<string> value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}>
|
||||
<SelectTrigger id="inv-pay-account" className="w-full text-base" aria-invalid={!!errors.glBankAccountId}>
|
||||
<SelectValue placeholder={accounts === null ? "Loading…" : "Select a cash/bank account"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(accounts ?? []).map((a) => (
|
||||
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
|
||||
{a.accountName} ({a.accountType})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FieldError errors={[errors.glBankAccountId ? { message: errors.glBankAccountId } : undefined]} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-pay-invoice-no">Invoice no.</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-invoice-no"
|
||||
value={invoice.invoiceNo}
|
||||
readOnly
|
||||
tabIndex={-1}
|
||||
className="cursor-not-allowed bg-muted text-muted-foreground"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={!!errors.amount}>
|
||||
<FieldLabel htmlFor="inv-pay-amount">Amount</FieldLabel>
|
||||
<Input
|
||||
id="inv-pay-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
max={invoice.totals.balanceAmount}
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
aria-invalid={!!errors.amount}
|
||||
/>
|
||||
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
|
||||
</Field>
|
||||
<Field data-invalid={!!errors.paymentDate}>
|
||||
<FieldLabel htmlFor="inv-pay-date">Payment date</FieldLabel>
|
||||
<Input
|
||||
id="inv-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>
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<FieldLabel htmlFor="inv-pay-reference">Reference (optional)</FieldLabel>
|
||||
<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} />
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
@@ -160,11 +187,7 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submit} disabled={submitting}>
|
||||
{submitting
|
||||
? "Recording…"
|
||||
: invoice.status === "Draft"
|
||||
? "Confirm & record payment"
|
||||
: "Record payment"}
|
||||
{submitting ? "Recording…" : isDraft ? "Confirm & record payment" : "Record payment"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react"
|
||||
import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { Printer } from "lucide-react"
|
||||
import { Download, Loader2, Printer, Share2 } from "lucide-react"
|
||||
|
||||
import { salesApi } from "@/lib/api/sales"
|
||||
import { customersApi } from "@/lib/api/customers"
|
||||
@@ -12,7 +12,9 @@ import { errorMessage } from "@/lib/error-map"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { toast } from "@/components/ui/toast"
|
||||
import { CompanyProfile } from "@/types/company-profile"
|
||||
import { Customer } from "@/types/customers"
|
||||
import { InvoiceTemplateConfig } from "@/types/invoice-template"
|
||||
@@ -21,17 +23,26 @@ import { SalesInvoice, SalesInvoicePayment } from "@/types/sales"
|
||||
// Deep-red accent from the reference layout — used for the heading underlines and header rule.
|
||||
const ACCENT = "#8b1c24"
|
||||
|
||||
type PaperSize = "A4" | "80mm"
|
||||
type PaperSize = "A4" | "80mm" | "58mm"
|
||||
type ReceiptWidth = Exclude<PaperSize, "A4">
|
||||
|
||||
const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [
|
||||
{ value: "A4", label: "A4", hint: "210 × 297 mm" },
|
||||
{ value: "80mm", label: "80mm receipt", hint: "Thermal roll" },
|
||||
{ value: "58mm", label: "58mm receipt", hint: "Small thermal roll" },
|
||||
]
|
||||
|
||||
/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */
|
||||
const PAGE_RULES: Record<PaperSize, string> = {
|
||||
A4: "@page { size: A4; margin: 12mm; }",
|
||||
"80mm": "@page { size: 80mm auto; margin: 3mm; }",
|
||||
"58mm": "@page { size: 58mm auto; margin: 2mm; }",
|
||||
}
|
||||
|
||||
/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */
|
||||
const RECEIPT_SHEET_CLASS: Record<ReceiptWidth, string> = {
|
||||
"80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]",
|
||||
"58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]",
|
||||
}
|
||||
|
||||
function money(n: number) {
|
||||
@@ -265,12 +276,15 @@ function ReceiptRow({ label, value, bold }: { label: string; value: string; bold
|
||||
)
|
||||
}
|
||||
|
||||
/** 80mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo }: InvoiceDoc) {
|
||||
/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */
|
||||
function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) {
|
||||
const t = invoice.totals
|
||||
return (
|
||||
<div
|
||||
className="invoice-sheet mx-auto w-[80mm] shrink-0 bg-white px-[4mm] py-[5mm] text-[11px] leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0"
|
||||
className={cn(
|
||||
"invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0",
|
||||
RECEIPT_SHEET_CLASS[width]
|
||||
)}
|
||||
style={printColorExact}
|
||||
>
|
||||
<div className="text-center">
|
||||
@@ -358,7 +372,7 @@ function ReceiptSheet({ invoice, customer, payments, company, template, dueDate,
|
||||
}
|
||||
/** Loads the invoice plus the optional decoration around it (customer, payments, company,
|
||||
* template). Only the invoice is required — any other failure just leaves that block out. */
|
||||
function useInvoiceDoc(invoiceId: number) {
|
||||
function useInvoiceDoc(invoiceId: number, refreshKey = 0) {
|
||||
const [doc, setDoc] = useState<InvoiceDoc | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const error = Number.isFinite(invoiceId) ? loadError : `Invalid invoice id '${invoiceId}'.`
|
||||
@@ -404,7 +418,8 @@ function useInvoiceDoc(invoiceId: number) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [invoiceId])
|
||||
// refreshKey: bumped by the caller after a payment/confirm so the sheet shows the new state.
|
||||
}, [invoiceId, refreshKey])
|
||||
|
||||
return { doc, error }
|
||||
}
|
||||
@@ -416,8 +431,57 @@ function useIsClient() {
|
||||
return useSyncExternalStore(subscribeNoop, () => true, () => false)
|
||||
}
|
||||
|
||||
/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */
|
||||
const PAPER_WIDTH_MM: Record<PaperSize, number> = { A4: 210, "80mm": 80, "58mm": 58 }
|
||||
const A4_HEIGHT_MM = 297
|
||||
|
||||
/**
|
||||
* Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured
|
||||
* as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split
|
||||
* across as many 297mm pages as needed; a receipt is one page as long as the receipt itself.
|
||||
* Both libraries are loaded on demand.
|
||||
*/
|
||||
async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise<Blob> {
|
||||
const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")])
|
||||
// The sheet's drop shadow / ring are screen decoration only.
|
||||
const dataUrl = await toPng(sheet, {
|
||||
pixelRatio: 2,
|
||||
backgroundColor: "#ffffff",
|
||||
style: { boxShadow: "none", margin: "0" },
|
||||
// A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF.
|
||||
imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
||||
})
|
||||
const img = new Image()
|
||||
img.src = dataUrl
|
||||
await img.decode()
|
||||
|
||||
const widthMm = PAPER_WIDTH_MM[size]
|
||||
const imgHeightMm = (img.height * widthMm) / img.width
|
||||
if (size !== "A4") {
|
||||
const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] })
|
||||
pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm)
|
||||
return pdf.output("blob")
|
||||
}
|
||||
const pdf = new jsPDF({ unit: "mm", format: "a4" })
|
||||
// Same image on every page, shifted up one page-height each time; the page box crops the rest.
|
||||
for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) {
|
||||
if (page > 0) pdf.addPage()
|
||||
pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm)
|
||||
}
|
||||
return pdf.output("blob")
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} />
|
||||
return size === "A4" ? <A4Sheet {...doc} /> : <ReceiptSheet {...doc} width={size} />
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,10 +489,55 @@ function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) {
|
||||
* portalled straight into <body> (`.invoice-print-root`, see globals.css) so the printout is
|
||||
* just the invoice, whether this is rendered in a page or inside a dialog.
|
||||
*/
|
||||
export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId: number; className?: string }) {
|
||||
const { doc, error } = useInvoiceDoc(invoiceId)
|
||||
export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) {
|
||||
const { doc, error } = useInvoiceDoc(invoiceId, refreshKey)
|
||||
const [paperSize, setPaperSize] = useState<PaperSize>("A4")
|
||||
const isClient = useIsClient()
|
||||
const previewRef = useRef<HTMLDivElement>(null)
|
||||
const [busy, setBusy] = useState<"download" | "share" | null>(null)
|
||||
|
||||
const fileName = `${doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf`
|
||||
|
||||
async function makePdf(): Promise<Blob> {
|
||||
const sheet = previewRef.current?.querySelector<HTMLElement>(".invoice-sheet")
|
||||
if (!sheet) throw new Error("The invoice is still loading.")
|
||||
return buildInvoicePdf(sheet, paperSize)
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
setBusy("download")
|
||||
try {
|
||||
saveBlob(await makePdf(), fileName)
|
||||
} catch (err) {
|
||||
toast.error("Could not create the PDF", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge);
|
||||
// otherwise shares — or, failing that, copies — a link to this invoice's print page.
|
||||
async function handleShare() {
|
||||
const title = `Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()
|
||||
const link = `${window.location.origin}/dashboard/sales/invoices/${invoiceId}/print`
|
||||
setBusy("share")
|
||||
try {
|
||||
const file = new File([await makePdf()], fileName, { type: "application/pdf" })
|
||||
if (navigator.canShare?.({ files: [file] })) {
|
||||
await navigator.share({ files: [file], title, text: title })
|
||||
} else if (navigator.share) {
|
||||
await navigator.share({ title, text: title, url: link })
|
||||
} else {
|
||||
await navigator.clipboard.writeText(link)
|
||||
toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.")
|
||||
}
|
||||
} catch (err) {
|
||||
// Closing the share sheet isn't an error.
|
||||
if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !doc) {
|
||||
return <div className="m-4 rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
|
||||
@@ -437,7 +546,7 @@ export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId:
|
||||
return (
|
||||
<div className={cn("flex min-h-0 flex-col", className)}>
|
||||
{/* Preview window — scrolls so the page keeps its true size. */}
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
||||
<div ref={previewRef} className="min-h-0 flex-1 overflow-auto bg-muted/40 p-4 sm:p-6">
|
||||
{doc ? (
|
||||
<Sheet size={paperSize} doc={doc} />
|
||||
) : (
|
||||
@@ -448,30 +557,41 @@ export function SalesInvoicePrintPreview({ invoiceId, className }: { invoiceId:
|
||||
{/* Paper size selection, under the invoice. */}
|
||||
<div className="flex flex-col gap-3 border-t bg-card p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="text-sm font-semibold text-foreground">Paper size</span>
|
||||
<div role="radiogroup" aria-label="Paper size" className="inline-flex w-fit rounded-lg border bg-muted/40 p-1">
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<button
|
||||
key={s.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={paperSize === s.value}
|
||||
onClick={() => setPaperSize(s.value)}
|
||||
className={cn(
|
||||
"flex flex-col items-start rounded-md px-4 py-1.5 text-left transition-colors",
|
||||
paperSize === s.value ? "bg-background text-foreground shadow-sm ring-1 ring-foreground/10" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label htmlFor="invoice-paper-size" className="text-sm font-semibold text-foreground">Paper size</label>
|
||||
<Select<PaperSize>
|
||||
value={paperSize}
|
||||
onValueChange={(v) => v && setPaperSize(v)}
|
||||
items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))}
|
||||
>
|
||||
<SelectTrigger id="invoice-paper-size" className="h-10! w-56 text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAPER_SIZES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-sm font-medium">{s.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{s.hint}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="lg" variant="outline" onClick={handleShare} disabled={!doc || busy !== null}>
|
||||
{busy === "share" ? <Loader2 className="size-4 animate-spin" /> : <Share2 className="size-4" />}
|
||||
Share
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={handleDownload} disabled={!doc || busy !== null}>
|
||||
{busy === "download" ? <Loader2 className="size-4 animate-spin" /> : <Download className="size-4" />}
|
||||
Download
|
||||
</Button>
|
||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||
<Printer className="size-4" />
|
||||
Print {paperSize === "A4" ? "A4" : `${paperSize} receipt`}
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="lg" onClick={() => window.print()} disabled={!doc}>
|
||||
<Printer className="size-4" />
|
||||
Print {paperSize === "A4" ? "A4" : "receipt"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isClient &&
|
||||
@@ -493,20 +613,29 @@ export function SalesInvoicePrintDialog({
|
||||
invoiceNo,
|
||||
open,
|
||||
onOpenChange,
|
||||
actions,
|
||||
refreshKey,
|
||||
}: {
|
||||
invoiceId: number | null
|
||||
invoiceNo?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** Optional buttons shown in the header (e.g. Confirm / Pay) — turns the preview into an invoice view. */
|
||||
actions?: ReactNode
|
||||
/** Bump to reload the invoice (after a payment, confirm, …). */
|
||||
refreshKey?: number
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[calc(100%-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(960px,calc(100%-2rem))]">
|
||||
<div className="border-b px-5 py-4 pr-12">
|
||||
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
<div className="flex flex-col gap-3 border-b px-5 py-4 pr-12 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-semibold">Invoice{invoiceNo ? ` ${invoiceNo}` : ""}</DialogTitle>
|
||||
<DialogDescription>Preview, pick a paper size below, then print.</DialogDescription>
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} className="flex-1" />}
|
||||
{invoiceId !== null && <SalesInvoicePrintPreview invoiceId={invoiceId} refreshKey={refreshKey} className="flex-1" />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user