@voyantjs/finance-ui 0.81.14 → 0.81.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ import { type InvoiceRecord } from "@voyantjs/finance-react";
2
+ export interface BookingInvoiceDialogUpload {
3
+ storageKey: string;
4
+ mimeType: string;
5
+ fileSize: number;
6
+ }
7
+ export interface BookingInvoiceDialogProps {
8
+ open: boolean;
9
+ onOpenChange: (open: boolean) => void;
10
+ bookingId: string;
11
+ /** Pre-fill the currency from the booking's sell currency. */
12
+ defaultCurrency?: string;
13
+ /** Pre-fill subtotal/total from the booking's sell amount (in cents). */
14
+ defaultAmountCents?: number | null;
15
+ /**
16
+ * Upload a file's bytes to durable storage and return its location so
17
+ * the dialog can attach it to the newly-created invoice. When omitted,
18
+ * the attachments dropzone is hidden — the SmartBill-off branch can
19
+ * still create the invoice, it just won't surface uploads. The
20
+ * template owns the upload endpoint (e.g. `/api/v1/uploads`) so the
21
+ * dialog stays transport-agnostic.
22
+ */
23
+ uploadFile?: (file: File) => Promise<BookingInvoiceDialogUpload>;
24
+ /**
25
+ * Tax % to pre-fill on the schedule-derived line item. The operator
26
+ * template resolves this from the booking's primary product (e.g. via
27
+ * `useBookingTaxPreview`) so the dialog mirrors the rate the server
28
+ * would apply server-side at issuance. Defaults to 0 when omitted.
29
+ */
30
+ defaultScheduleTaxRatePercent?: number;
31
+ onSuccess?: (invoice: InvoiceRecord) => void;
32
+ }
33
+ /**
34
+ * Modal invoice creator scoped to a single booking. Operators can pick
35
+ * between a free-form ("custom") invoice and one derived from an
36
+ * unpaid payment schedule (amounts + due date are locked to the
37
+ * schedule). Toggles control whether the new invoice is pushed to
38
+ * SmartBill on issue, whether a fully-paid `payments` row is created
39
+ * alongside it, and (when sync is off) lets the operator attach
40
+ * supporting documents.
41
+ */
42
+ export declare function BookingInvoiceDialog({ open, onOpenChange, bookingId, defaultCurrency, // i18n-literal-ok domain default currency
43
+ defaultAmountCents, uploadFile, defaultScheduleTaxRatePercent, onSuccess, }: BookingInvoiceDialogProps): import("react/jsx-runtime").JSX.Element;
44
+ //# sourceMappingURL=booking-invoice-dialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"booking-invoice-dialog.d.ts","sourceRoot":"","sources":["../../src/components/booking-invoice-dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,aAAa,EAKnB,MAAM,yBAAyB,CAAA;AAqChC,MAAM,WAAW,0BAA0B;IACzC,UAAU,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,MAAM,WAAW,yBAAyB;IACxC,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,8DAA8D;IAC9D,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,yEAAyE;IACzE,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAClC;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,0BAA0B,CAAC,CAAA;IAChE;;;;;OAKG;IACH,6BAA6B,CAAC,EAAE,MAAM,CAAA;IACtC,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,aAAa,KAAK,IAAI,CAAA;CAC7C;AAsDD;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,EACnC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,eAAuB,EAAE,0CAA0C;AACnE,kBAAyB,EACzB,UAAU,EACV,6BAAiC,EACjC,SAAS,GACV,EAAE,yBAAyB,2CAuqB3B"}
@@ -0,0 +1,349 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useBookingPaymentSchedules, useInvoiceMutation, useVoyantFinanceContext, } from "@voyantjs/finance-react";
4
+ import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from "@voyantjs/ui/components";
5
+ import { CurrencyCombobox } from "@voyantjs/ui/components/currency-combobox";
6
+ import { CurrencyInput } from "@voyantjs/ui/components/currency-input";
7
+ import { DatePicker } from "@voyantjs/ui/components/date-picker";
8
+ import { Loader2, Paperclip, Plus, X } from "lucide-react";
9
+ import { useCallback, useEffect, useMemo, useState } from "react";
10
+ import { useFinanceUiMessagesOrDefault } from "../i18n/index.js";
11
+ function generateInvoiceNumber() {
12
+ const now = new Date();
13
+ const y = now.getFullYear();
14
+ const seq = String(Math.floor(Math.random() * 9000) + 1000);
15
+ return `INV-${y}-${seq}`; // i18n-literal-ok auto-generated id
16
+ }
17
+ function formatScheduleDate(iso, locale) {
18
+ try {
19
+ return new Date(iso).toLocaleDateString(locale, { dateStyle: "medium" });
20
+ }
21
+ catch {
22
+ return iso;
23
+ }
24
+ }
25
+ function formatMoney(cents, currency) {
26
+ try {
27
+ return new Intl.NumberFormat(undefined, { style: "currency", currency }).format(cents / 100);
28
+ }
29
+ catch {
30
+ return `${(cents / 100).toFixed(2)} ${currency}`;
31
+ }
32
+ }
33
+ const SCHEDULE_DESCRIPTION_FALLBACK = {
34
+ deposit: "Deposit",
35
+ installment: "Installment",
36
+ balance: "Balance",
37
+ hold: "Hold",
38
+ other: "Payment",
39
+ };
40
+ function scheduleDescription(scheduleType) {
41
+ if (!scheduleType)
42
+ return SCHEDULE_DESCRIPTION_FALLBACK.other ?? "Payment";
43
+ return SCHEDULE_DESCRIPTION_FALLBACK[scheduleType] ?? scheduleType;
44
+ }
45
+ // CurrencyInput requires `onChange` even when disabled; this placeholder
46
+ // keeps the read-only totals row from needing per-call wrappers.
47
+ const noopCurrencyChange = (_value) => { };
48
+ const PAYMENT_METHODS = [
49
+ "bank_transfer",
50
+ "credit_card",
51
+ "debit_card",
52
+ "cash",
53
+ "cheque",
54
+ "wallet",
55
+ "direct_bill",
56
+ "voucher",
57
+ "other",
58
+ ];
59
+ /**
60
+ * Modal invoice creator scoped to a single booking. Operators can pick
61
+ * between a free-form ("custom") invoice and one derived from an
62
+ * unpaid payment schedule (amounts + due date are locked to the
63
+ * schedule). Toggles control whether the new invoice is pushed to
64
+ * SmartBill on issue, whether a fully-paid `payments` row is created
65
+ * alongside it, and (when sync is off) lets the operator attach
66
+ * supporting documents.
67
+ */
68
+ export function BookingInvoiceDialog({ open, onOpenChange, bookingId, defaultCurrency = "EUR", // i18n-literal-ok domain default currency
69
+ defaultAmountCents = null, uploadFile, defaultScheduleTaxRatePercent = 0, onSuccess, }) {
70
+ const { createFromBooking } = useInvoiceMutation();
71
+ const { baseUrl, fetcher } = useVoyantFinanceContext();
72
+ const messages = useFinanceUiMessagesOrDefault();
73
+ const dialog = messages.invoiceDialog;
74
+ const [invoiceType, setInvoiceType] = useState("invoice");
75
+ const [source, setSource] = useState("schedule");
76
+ const [scheduleId, setScheduleId] = useState(null);
77
+ const [invoiceNumber, setInvoiceNumber] = useState("");
78
+ const [currency, setCurrency] = useState(defaultCurrency);
79
+ const [subtotalCents, setSubtotalCents] = useState(defaultAmountCents ?? 0);
80
+ const [taxCents, setTaxCents] = useState(0);
81
+ const [totalCents, setTotalCents] = useState(defaultAmountCents ?? 0);
82
+ const [issueDate, setIssueDate] = useState("");
83
+ const [dueDate, setDueDate] = useState("");
84
+ const [notes, setNotes] = useState("");
85
+ const [lineItems, setLineItems] = useState([]);
86
+ const [syncToSmartbill, setSyncToSmartbill] = useState(true);
87
+ const [markAsPaid, setMarkAsPaid] = useState(false);
88
+ const [markAsPaidMethod, setMarkAsPaidMethod] = useState("bank_transfer");
89
+ const [markAsPaidDate, setMarkAsPaidDate] = useState("");
90
+ const [attachments, setAttachments] = useState([]);
91
+ const [submitting, setSubmitting] = useState(false);
92
+ const [error, setError] = useState(null);
93
+ // ---- prefill / reset on open ---------------------------------------------
94
+ useEffect(() => {
95
+ if (!open)
96
+ return;
97
+ const today = new Date().toISOString().split("T")[0] ?? "";
98
+ setInvoiceType("invoice");
99
+ setSource("schedule");
100
+ setScheduleId(null);
101
+ setInvoiceNumber(generateInvoiceNumber());
102
+ setCurrency(defaultCurrency);
103
+ setSubtotalCents(defaultAmountCents ?? 0);
104
+ setTaxCents(0);
105
+ setTotalCents(defaultAmountCents ?? 0);
106
+ setIssueDate(today);
107
+ setDueDate("");
108
+ setNotes("");
109
+ setLineItems([]);
110
+ setSyncToSmartbill(true);
111
+ setMarkAsPaid(false);
112
+ setMarkAsPaidMethod("bank_transfer");
113
+ setMarkAsPaidDate(today);
114
+ setAttachments([]);
115
+ setSubmitting(false);
116
+ setError(null);
117
+ }, [open, defaultCurrency, defaultAmountCents]);
118
+ // ---- schedules -----------------------------------------------------------
119
+ const schedulesQuery = useBookingPaymentSchedules(bookingId, { enabled: open });
120
+ const unpaidSchedules = useMemo(() => (schedulesQuery.data?.data ?? []).filter((s) => s.status === "pending" || s.status === "due"), [schedulesQuery.data]);
121
+ // When the operator picks a schedule, lock the financial fields to it.
122
+ // `useMemo` keeps the lookup cheap on every render.
123
+ const selectedSchedule = useMemo(() => (scheduleId ? (unpaidSchedules.find((s) => s.id === scheduleId) ?? null) : null), [scheduleId, unpaidSchedules]);
124
+ useEffect(() => {
125
+ if (source !== "schedule") {
126
+ // Leaving the schedule branch — wipe the auto-prefilled line items
127
+ // so the operator's blank "Custom" slate stays blank.
128
+ setLineItems([]);
129
+ return;
130
+ }
131
+ if (!selectedSchedule) {
132
+ setLineItems([]);
133
+ return;
134
+ }
135
+ setCurrency(selectedSchedule.currency);
136
+ setDueDate(selectedSchedule.dueDate);
137
+ // The schedule amount is the gross (customer-facing) sum, so the
138
+ // line item's net unit price is `amount / (1 + tax%)`. Without this
139
+ // back-out the line would compute as `amount + amount * tax%`,
140
+ // making the invoice exceed what the customer is actually paying.
141
+ const taxFactor = 1 + Math.max(0, defaultScheduleTaxRatePercent) / 100;
142
+ const netUnitAmount = taxFactor > 0
143
+ ? Math.round(selectedSchedule.amountCents / taxFactor)
144
+ : selectedSchedule.amountCents;
145
+ setLineItems([
146
+ {
147
+ id: `schedule_${selectedSchedule.id}`,
148
+ description: scheduleDescription(selectedSchedule.scheduleType),
149
+ quantity: 1,
150
+ unitAmountCents: netUnitAmount,
151
+ taxRatePercent: defaultScheduleTaxRatePercent,
152
+ },
153
+ ]);
154
+ }, [source, selectedSchedule, defaultScheduleTaxRatePercent]);
155
+ // ---- line item totals ----------------------------------------------------
156
+ // When the operator entered explicit line items, the global Subtotal/Tax/
157
+ // Total fields become a read-only summary of the sum across rows.
158
+ const lineItemTotals = useMemo(() => {
159
+ let subtotal = 0;
160
+ let tax = 0;
161
+ for (const line of lineItems) {
162
+ const lineSubtotal = Math.max(0, Math.round(line.quantity * line.unitAmountCents));
163
+ const lineTax = Math.max(0, Math.round((lineSubtotal * line.taxRatePercent) / 100));
164
+ subtotal += lineSubtotal;
165
+ tax += lineTax;
166
+ }
167
+ return { subtotalCents: subtotal, taxCents: tax, totalCents: subtotal + tax };
168
+ }, [lineItems]);
169
+ // Subtotal/Tax/Total are always derived from the line items, so reflect
170
+ // the latest computation on every render (including the empty → 0 case).
171
+ useEffect(() => {
172
+ setSubtotalCents(lineItemTotals.subtotalCents);
173
+ setTaxCents(lineItemTotals.taxCents);
174
+ setTotalCents(lineItemTotals.totalCents);
175
+ }, [lineItemTotals]);
176
+ const scheduleLocked = source === "schedule" && selectedSchedule != null;
177
+ const linesDriveTotals = source === "custom" && lineItems.length > 0;
178
+ // ---- submit --------------------------------------------------------------
179
+ const submit = useCallback(async () => {
180
+ if (submitting)
181
+ return;
182
+ setError(null);
183
+ if (source === "schedule" && !selectedSchedule) {
184
+ setError(dialog.schedulePlaceholder);
185
+ return;
186
+ }
187
+ if (!issueDate) {
188
+ setError(dialog.validation.issueDateRequired);
189
+ return;
190
+ }
191
+ if (!dueDate) {
192
+ setError(dialog.validation.dueDateRequired);
193
+ return;
194
+ }
195
+ if (linesDriveTotals) {
196
+ const invalid = lineItems.find((line) => !line.description.trim() || line.quantity < 1);
197
+ if (invalid) {
198
+ setError(dialog.validation.lineItemInvalid);
199
+ return;
200
+ }
201
+ }
202
+ setSubmitting(true);
203
+ try {
204
+ // Subtotal/Tax/Total are intentionally omitted — the server
205
+ // computes them from `lineItems` (custom mode) or from the linked
206
+ // schedule (schedule mode). Sending the locally-derived numbers
207
+ // can drift by 1 cent due to rounding and trips the strict
208
+ // "Invoice tax does not match line items" cross-check.
209
+ const created = await createFromBooking.mutateAsync({
210
+ bookingId,
211
+ bookingPaymentScheduleId: selectedSchedule?.id,
212
+ invoiceNumber,
213
+ issueDate,
214
+ dueDate,
215
+ currency,
216
+ notes: notes || null,
217
+ invoiceType,
218
+ skipExternalSync: !syncToSmartbill,
219
+ lineItems: linesDriveTotals
220
+ ? lineItems.map((line) => ({
221
+ description: line.description.trim(),
222
+ quantity: line.quantity,
223
+ unitAmountCents: line.unitAmountCents,
224
+ taxRateBps: line.taxRatePercent > 0 ? Math.round(line.taxRatePercent * 100) : undefined,
225
+ }))
226
+ : undefined,
227
+ });
228
+ // Fully-paid sibling payment row. Use the totals returned by the
229
+ // server (which may differ from the locally-derived ones by a cent
230
+ // due to schedule tax back-out / rounding) — paying the local
231
+ // figure can leave the invoice short and break the one-click
232
+ // "Mark as paid" expectation (incl. proforma conversion triggers).
233
+ if (markAsPaid && created.totalCents > 0) {
234
+ const paymentDate = markAsPaidDate || new Date().toISOString().split("T")[0] || issueDate;
235
+ await fetcher(`${baseUrl}/v1/admin/finance/invoices/${created.id}/payments`, {
236
+ method: "POST",
237
+ headers: { "Content-Type": "application/json" },
238
+ body: JSON.stringify({
239
+ amountCents: created.totalCents,
240
+ currency: created.currency,
241
+ paymentMethod: markAsPaidMethod,
242
+ status: "completed",
243
+ paymentDate,
244
+ }),
245
+ });
246
+ }
247
+ // Attachments: upload bytes via the template-supplied uploader,
248
+ // then register each as an `invoice_attachment` row pointing at
249
+ // the storage key returned by the upload.
250
+ if (uploadFile && attachments.length > 0) {
251
+ for (const file of attachments) {
252
+ const uploaded = await uploadFile(file);
253
+ await fetcher(`${baseUrl}/v1/admin/finance/invoices/${created.id}/attachments`, {
254
+ method: "POST",
255
+ headers: { "Content-Type": "application/json" },
256
+ body: JSON.stringify({
257
+ kind: "supporting_document",
258
+ name: file.name,
259
+ mimeType: uploaded.mimeType || file.type || null,
260
+ fileSize: uploaded.fileSize ?? file.size,
261
+ storageKey: uploaded.storageKey,
262
+ }),
263
+ });
264
+ }
265
+ }
266
+ onSuccess?.(created);
267
+ onOpenChange(false);
268
+ }
269
+ catch (err) {
270
+ setError(err instanceof Error ? err.message : String(err));
271
+ }
272
+ finally {
273
+ setSubmitting(false);
274
+ }
275
+ }, [
276
+ submitting,
277
+ source,
278
+ selectedSchedule,
279
+ issueDate,
280
+ dueDate,
281
+ createFromBooking,
282
+ bookingId,
283
+ invoiceNumber,
284
+ currency,
285
+ notes,
286
+ invoiceType,
287
+ syncToSmartbill,
288
+ markAsPaid,
289
+ markAsPaidMethod,
290
+ markAsPaidDate,
291
+ attachments,
292
+ uploadFile,
293
+ baseUrl,
294
+ fetcher,
295
+ onSuccess,
296
+ onOpenChange,
297
+ linesDriveTotals,
298
+ lineItems,
299
+ dialog,
300
+ ]);
301
+ // ---- render --------------------------------------------------------------
302
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { className: "w-full! max-w-3xl! gap-0 p-0", children: [_jsxs(DialogHeader, { className: "shrink-0 border-b px-6 py-4", children: [_jsx(DialogTitle, { children: dialog.titles.create }), _jsx(DialogDescription, { children: messages.invoicesPage.description })] }), _jsx("div", { className: "max-h-[70vh] overflow-y-auto", children: _jsxs("div", { className: "flex flex-col gap-4 px-6 py-5", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.type }), _jsx(SegmentedChoice, { value: invoiceType, onChange: setInvoiceType, options: [
303
+ { value: "invoice", label: dialog.typeLabels.invoice },
304
+ { value: "proforma", label: dialog.typeLabels.proforma },
305
+ ] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.source }), _jsx(SegmentedChoice, { value: source, onChange: (next) => {
306
+ setSource(next);
307
+ if (next === "custom")
308
+ setScheduleId(null);
309
+ }, options: [
310
+ { value: "schedule", label: dialog.sourceLabels.schedule },
311
+ { value: "custom", label: dialog.sourceLabels.custom },
312
+ ] })] }), _jsxs("div", { className: "flex items-center justify-between gap-3 rounded-md border bg-muted/20 px-4 py-3", children: [_jsx(Label, { className: "cursor-pointer", onClick: () => setSyncToSmartbill((v) => !v), children: dialog.fields.syncToSmartbill }), _jsx(Switch, { checked: syncToSmartbill, onCheckedChange: setSyncToSmartbill })] }), source === "schedule" ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.schedule }), _jsxs(Select, { value: scheduleId ?? undefined, onValueChange: (v) => setScheduleId(v ?? null), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, { placeholder: dialog.schedulePlaceholder }) }), _jsx(SelectContent, { children: unpaidSchedules.length === 0 ? (_jsx("div", { className: "px-3 py-2 text-sm text-muted-foreground", children: dialog.scheduleEmpty })) : (unpaidSchedules.map((s) => (_jsxs(SelectItem, { value: s.id, children: [formatScheduleDate(s.dueDate, undefined), " \u00B7", " ", formatMoney(s.amountCents, s.currency)] }, s.id)))) })] }), scheduleLocked ? (_jsx("p", { className: "text-xs text-muted-foreground", children: dialog.scheduleLockedHint })) : null] })) : null, _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.invoiceNumber }), _jsx(Input, { value: invoiceNumber, onChange: (e) => setInvoiceNumber(e.target.value), placeholder: dialog.placeholders.invoiceNumber, disabled: syncToSmartbill }), syncToSmartbill ? (_jsx("p", { className: "text-xs text-muted-foreground", children: dialog.invoiceNumberAutoHint })) : null] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.currency }), _jsx(CurrencyCombobox, { value: currency, onChange: (next) => setCurrency(next ?? defaultCurrency), disabled: scheduleLocked })] })] }), source === "custom" || lineItems.length > 0 ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx(Label, { children: dialog.lineItems.sectionTitle }), source === "custom" ? (_jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setLineItems((prev) => [
313
+ ...prev,
314
+ {
315
+ id: `tmp_${Date.now()}_${prev.length}`,
316
+ description: "",
317
+ quantity: 1,
318
+ unitAmountCents: 0,
319
+ taxRatePercent: 0,
320
+ },
321
+ ]), children: [_jsx(Plus, { className: "mr-1 h-3.5 w-3.5" }), dialog.lineItems.addRow] })) : null] }), lineItems.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground", children: dialog.lineItems.empty })) : (_jsx("div", { className: "flex flex-col gap-2", children: lineItems.map((line, idx) => {
322
+ const lineSubtotal = Math.max(0, Math.round(line.quantity * line.unitAmountCents));
323
+ const lineTotal = lineSubtotal +
324
+ Math.max(0, Math.round((lineSubtotal * line.taxRatePercent) / 100));
325
+ return (_jsxs("div", { className: "grid grid-cols-[1fr_3rem_7rem_4rem_6rem_2rem] items-end gap-2 rounded-md border bg-background p-2", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [idx === 0 ? (_jsx(Label, { className: "text-xs", children: dialog.lineItems.description })) : null, _jsx(Input, { value: line.description, onChange: (e) => setLineItems((prev) => prev.map((row, i) => i === idx ? { ...row, description: e.target.value } : row)), disabled: scheduleLocked })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [idx === 0 ? (_jsx(Label, { className: "text-xs", children: dialog.lineItems.quantity })) : null, _jsx(Input, { type: "number", min: 1, value: line.quantity, onChange: (e) => setLineItems((prev) => prev.map((row, i) => i === idx
326
+ ? {
327
+ ...row,
328
+ quantity: Math.max(1, Number(e.target.value) || 1),
329
+ }
330
+ : row)), disabled: scheduleLocked })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [idx === 0 ? (_jsx(Label, { className: "text-xs", children: dialog.lineItems.unitPrice })) : null, _jsx(CurrencyInput, { value: line.unitAmountCents, onChange: (next) => setLineItems((prev) => prev.map((row, i) => i === idx ? { ...row, unitAmountCents: next ?? 0 } : row)), currency: currency, disabled: scheduleLocked })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [idx === 0 ? (_jsx(Label, { className: "text-xs", children: dialog.lineItems.taxPercent })) : null, _jsx(Input, { type: "number", min: 0, max: 100, step: 0.01, value: line.taxRatePercent, onChange: (e) => setLineItems((prev) => prev.map((row, i) => i === idx
331
+ ? {
332
+ ...row,
333
+ taxRatePercent: Math.max(0, Math.min(100, Number(e.target.value) || 0)),
334
+ }
335
+ : row)), disabled: scheduleLocked })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [idx === 0 ? (_jsx(Label, { className: "text-xs", children: dialog.lineItems.lineTotal })) : null, _jsx("div", { className: "px-2 py-1.5 text-right font-mono text-sm", children: formatMoney(lineTotal, currency) })] }), scheduleLocked ? (_jsx("div", {})) : (_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", "aria-label": dialog.lineItems.remove, onClick: () => setLineItems((prev) => prev.filter((_, i) => i !== idx)), children: _jsx(X, { className: "h-3.5 w-3.5" }) }))] }, line.id));
336
+ }) }))] })) : null, _jsxs("div", { className: "grid grid-cols-3 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.subtotalCents }), _jsx(CurrencyInput, { value: subtotalCents, onChange: noopCurrencyChange, currency: currency, disabled: true })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.taxCents }), _jsx(CurrencyInput, { value: taxCents, onChange: noopCurrencyChange, currency: currency, disabled: true })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.totalCents }), _jsx(CurrencyInput, { value: totalCents, onChange: noopCurrencyChange, currency: currency, disabled: true })] })] }), _jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.issueDate }), _jsx(DatePicker, { value: issueDate || null, onChange: (next) => setIssueDate(next ?? ""), placeholder: dialog.placeholders.issueDate, className: "w-full" })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.dueDate }), _jsx(DatePicker, { value: dueDate || null, onChange: (next) => setDueDate(next ?? ""), placeholder: dialog.placeholders.dueDate, className: "w-full", disabled: scheduleLocked })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.notes }), _jsx(Textarea, { value: notes, onChange: (e) => setNotes(e.target.value), placeholder: dialog.placeholders.notes })] }), _jsxs("div", { className: "flex flex-col gap-3 rounded-md border bg-muted/20 px-4 py-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx(Label, { className: "cursor-pointer", onClick: () => setMarkAsPaid((v) => !v), children: dialog.fields.markAsPaid }), _jsx(Switch, { checked: markAsPaid, onCheckedChange: setMarkAsPaid })] }), markAsPaid ? (_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.markAsPaidMethod }), _jsxs(Select, { value: markAsPaidMethod, onValueChange: (v) => setMarkAsPaidMethod((v ?? "bank_transfer")), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: PAYMENT_METHODS.map((method) => (_jsx(SelectItem, { value: method, children: messages.common.paymentMethodLabels[method] }, method))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.markAsPaidDate }), _jsx(DatePicker, { value: markAsPaidDate || null, onChange: (next) => setMarkAsPaidDate(next ?? ""), placeholder: dialog.placeholders.issueDate, className: "w-full" })] })] })) : null] }), !syncToSmartbill && uploadFile ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.attachments }), _jsx("p", { className: "text-xs text-muted-foreground", children: dialog.attachmentsHint }), _jsx("input", { type: "file", multiple: true, onChange: (e) => {
337
+ const files = Array.from(e.target.files ?? []);
338
+ if (files.length > 0)
339
+ setAttachments((prev) => [...prev, ...files]);
340
+ e.target.value = "";
341
+ }, className: "block w-full text-sm file:mr-3 file:rounded-md file:border file:bg-muted file:px-3 file:py-1.5 file:text-sm file:font-medium hover:file:bg-muted/70" }), attachments.length > 0 ? (_jsx("ul", { className: "flex flex-col gap-1", children: attachments.map((file, idx) => (_jsxs("li", { className: "flex items-center justify-between gap-2 rounded-md border bg-background px-3 py-1.5 text-sm", children: [_jsxs("span", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Paperclip, { className: "h-3.5 w-3.5 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: file.name })] }), _jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", onClick: () => setAttachments((prev) => prev.filter((_, i) => i !== idx)), children: _jsx(X, { className: "h-3.5 w-3.5" }) })] }, `${file.name}-${idx}`))) })) : null] })) : null, error ? _jsx("p", { className: "text-sm text-destructive", children: error }) : null] }) }), _jsxs("div", { className: "flex shrink-0 items-center justify-end gap-2 border-t px-6 py-4", children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), disabled: submitting, children: messages.common.cancel }), _jsxs(Button, { type: "button", disabled: submitting, onClick: () => void submit(), children: [submitting ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : null, dialog.actions.create] })] })] }) }));
342
+ }
343
+ function SegmentedChoice({ value, onChange, options, }) {
344
+ return (_jsx("div", { className: "flex w-full rounded-md border bg-background p-0.5", children: options.map((opt) => {
345
+ const active = opt.value === value;
346
+ return (_jsx("button", { type: "button", onClick: () => onChange(opt.value), className: "flex-1 rounded-sm px-3 py-1.5 text-sm font-medium transition-colors " +
347
+ (active ? "bg-muted text-foreground" : "text-muted-foreground hover:text-foreground"), children: opt.label }, opt.value));
348
+ }) }));
349
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"record-booking-payment-dialog.d.ts","sourceRoot":"","sources":["../../src/components/record-booking-payment-dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,aAAa,EAOnB,MAAM,yBAAyB,CAAA;AA8BhC,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,aAAa,EAAE,aAAa,CAAA;IAC5B,MAAM,EAAE,aAAa,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;IACnB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAED,MAAM,WAAW,+BAA+B;IAC9C,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,0DAA0D;IAC1D,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAA;CAC/C;AAkED,wBAAgB,0BAA0B,CAAC,EACzC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,eAAuB,EACvB,UAAU,EACV,cAAqB,GACtB,EAAE,+BAA+B,2CAqbjC"}
1
+ {"version":3,"file":"record-booking-payment-dialog.d.ts","sourceRoot":"","sources":["../../src/components/record-booking-payment-dialog.tsx"],"names":[],"mappings":"AAEA,OAAO,EACL,KAAK,aAAa,EAClB,KAAK,aAAa,EAQnB,MAAM,yBAAyB,CAAA;AA6BhC,MAAM,WAAW,sBAAsB;IACrC,EAAE,EAAE,MAAM,CAAA;IACV,SAAS,EAAE,MAAM,CAAA;IACjB,WAAW,EAAE,MAAM,CAAA;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;IAC3B,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,aAAa,EAAE,aAAa,CAAA;IAC5B,MAAM,EAAE,aAAa,CAAA;IACrB,WAAW,EAAE,MAAM,CAAA;IACnB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAA;IAC9B,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CACrB;AAED,MAAM,WAAW,+BAA+B;IAC9C,IAAI,EAAE,OAAO,CAAA;IACb,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,CAAA;IACrC,SAAS,EAAE,MAAM,CAAA;IACjB,0DAA0D;IAC1D,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,sBAAsB,GAAG,IAAI,CAAA;CAC/C;AAkED,wBAAgB,0BAA0B,CAAC,EACzC,IAAI,EACJ,YAAY,EACZ,SAAS,EACT,eAAuB,EACvB,UAAU,EACV,cAAqB,GACtB,EAAE,+BAA+B,2CAsfjC"}
@@ -1,8 +1,8 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { paymentMethodSchema, paymentStatusSchema, useInvoiceFxRate, useInvoicePaymentMutation, useInvoices, usePaymentMutation, } from "@voyantjs/finance-react";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { paymentMethodSchema, paymentStatusSchema, useInvoiceFxRate, useInvoiceMutation, useInvoicePaymentMutation, useInvoices, usePaymentMutation, } from "@voyantjs/finance-react";
4
4
  import { formatMessage } from "@voyantjs/i18n";
5
- import { Button, Dialog, DialogBody, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, } from "@voyantjs/ui/components";
5
+ import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from "@voyantjs/ui/components";
6
6
  import { CurrencyCombobox } from "@voyantjs/ui/components/currency-combobox";
7
7
  import { CurrencyInput } from "@voyantjs/ui/components/currency-input";
8
8
  import { DatePicker } from "@voyantjs/ui/components/date-picker";
@@ -63,19 +63,25 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
63
63
  const isEditing = Boolean(editingPayment);
64
64
  const invoicesQuery = useInvoices({ bookingId, enabled: open });
65
65
  const invoices = invoicesQuery.data?.data ?? [];
66
- const paymentEligibleInvoices = invoices.filter((inv) => inv.status !== "void");
67
- const outstandingInvoices = paymentEligibleInvoices.filter((inv) => inv.balanceDueCents > 0);
68
- const selectableInvoices = outstandingInvoices.length > 0 ? outstandingInvoices : paymentEligibleInvoices;
66
+ // Operator records a payment AGAINST money still owed. Paid / voided
67
+ // invoices have no remaining balance to settle, so they don't belong
68
+ // in the picker keep the surface focused on what's actionable.
69
+ const selectableInvoices = invoices.filter((inv) => inv.status !== "void" && inv.balanceDueCents > 0);
69
70
  const [state, setState] = React.useState(() => buildInitialFormState(defaultCurrency));
70
71
  const [submitError, setSubmitError] = React.useState(null);
72
+ const [convertProformaAfter, setConvertProformaAfter] = React.useState(false);
73
+ const convertProformaTouchedRef = React.useRef(false);
71
74
  const initializedRef = React.useRef(false);
72
- // When the dialog opens, reset to defaults. Once invoices load, auto-pick
73
- // the only outstanding invoice (or the first invoice) and pre-fill the
74
- // amount with its balance due what the operator almost always wants.
75
+ // Reset on close; in edit mode, prefill from the snapshot once the
76
+ // invoices list loads. In create mode the operator picks an invoice
77
+ // explicitly selecting one fills amount/currency from the row.
78
+ // biome-ignore lint/correctness/useExhaustiveDependencies: one-shot init guarded by `initializedRef`; we intentionally don't re-fire when the derived `selectableInvoices[0]` reference changes
75
79
  React.useEffect(() => {
76
80
  if (!open) {
77
81
  initializedRef.current = false;
78
82
  setSubmitError(null);
83
+ setConvertProformaAfter(false);
84
+ convertProformaTouchedRef.current = false;
79
85
  return;
80
86
  }
81
87
  if (!initializedRef.current && !invoicesQuery.isLoading) {
@@ -93,6 +99,10 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
93
99
  });
94
100
  }
95
101
  else {
102
+ // Auto-select the first outstanding invoice so the operator
103
+ // doesn't have to click the picker when there's only one
104
+ // option (the common case). The user can still re-pick from
105
+ // the dropdown.
96
106
  const target = selectableInvoices[0];
97
107
  setState({
98
108
  ...buildInitialFormState(defaultCurrency),
@@ -103,7 +113,7 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
103
113
  }
104
114
  initializedRef.current = true;
105
115
  }
106
- }, [open, invoicesQuery.isLoading, selectableInvoices, defaultCurrency, editingPayment]);
116
+ }, [open, invoicesQuery.isLoading, defaultCurrency, editingPayment]);
107
117
  const selectedInvoice = invoices.find((inv) => inv.id === state.invoiceId) ?? null;
108
118
  const invoiceCurrency = selectedInvoice?.currency ?? "";
109
119
  const normalizedInvoiceCurrency = normalizeCurrency(invoiceCurrency);
@@ -112,6 +122,13 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
112
122
  const requiresBaseAmount = isCrossCurrency && state.status === "completed";
113
123
  const createMutation = useInvoicePaymentMutation(state.invoiceId);
114
124
  const { update: updateMutation } = usePaymentMutation();
125
+ const { convertToInvoice: convertProformaMutation } = useInvoiceMutation();
126
+ // Only offer the proforma→invoice conversion when (a) recording a
127
+ // brand-new payment (not editing), (b) the selected invoice is a
128
+ // proforma, (c) the payment is being marked completed (a pending or
129
+ // failed payment shouldn't trigger conversion).
130
+ const isProformaSelected = selectedInvoice?.invoiceType === "proforma";
131
+ const canConvertProformaAfter = !isEditing && isProformaSelected && state.status === "completed";
115
132
  const isPending = isEditing ? updateMutation.isPending : createMutation.isPending;
116
133
  const fxRateQuery = useInvoiceFxRate({
117
134
  baseCurrency: normalizedInvoiceCurrency || undefined,
@@ -135,6 +152,22 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
135
152
  !fxRateQuery.isFetching &&
136
153
  !autoEffectiveRate &&
137
154
  fxRateQuery.isFetched;
155
+ // Heuristic: prefill the "Convert proforma to invoice" switch ON
156
+ // only when this payment will close the proforma — i.e. the entered
157
+ // amount (in invoice currency, directly or via FX) covers the
158
+ // invoice's remaining balance. Partial payments stay off since the
159
+ // proforma still has balance due after recording. The operator can
160
+ // override either way; once they toggle, we freeze the choice.
161
+ const fullyCoversInvoiceBalance = selectedInvoice != null &&
162
+ selectedInvoice.balanceDueCents > 0 &&
163
+ (isCrossCurrency
164
+ ? baseAmountCents > 0 && baseAmountCents >= selectedInvoice.balanceDueCents
165
+ : state.amountCents > 0 && state.amountCents >= selectedInvoice.balanceDueCents);
166
+ React.useEffect(() => {
167
+ if (convertProformaTouchedRef.current)
168
+ return;
169
+ setConvertProformaAfter(canConvertProformaAfter && fullyCoversInvoiceBalance);
170
+ }, [canConvertProformaAfter, fullyCoversInvoiceBalance]);
138
171
  const set = (key, value) => setState((prev) => ({ ...prev, [key]: value }));
139
172
  const setPaymentCurrency = (currency) => {
140
173
  const nextCurrency = normalizeCurrency(currency) || normalizedInvoiceCurrency || defaultCurrency;
@@ -198,6 +231,14 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
198
231
  }
199
232
  else {
200
233
  await createMutation.mutateAsync(payload);
234
+ // The proforma → final invoice conversion is the last step so a
235
+ // failure here doesn't roll back the payment (it's already in).
236
+ // Server-side `convert-to-invoice` voids the proforma, creates
237
+ // the final invoice, and moves payments onto it — exactly what
238
+ // the operator would otherwise do by hand from the Invoices tab.
239
+ if (canConvertProformaAfter && convertProformaAfter && selectedInvoice) {
240
+ await convertProformaMutation.mutateAsync({ id: selectedInvoice.id });
241
+ }
201
242
  }
202
243
  onOpenChange(false);
203
244
  onRecorded?.();
@@ -206,8 +247,8 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
206
247
  setSubmitError(err instanceof Error ? err.message : dialog.validation.recordFailed);
207
248
  }
208
249
  };
209
- const descriptionParts = dialog.description.split("{generateLink}");
210
- return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { size: "lg", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: isEditing ? dialog.editTitle : dialog.title }) }), _jsxs("form", { onSubmit: handleSubmit, children: [_jsxs(DialogBody, { className: "grid gap-4", children: [isEditing ? null : (_jsxs("p", { className: "text-sm text-muted-foreground", children: [descriptionParts[0], _jsx("span", { className: "font-medium", children: dialog.generateLinkLabel }), descriptionParts[1]] })), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-invoice", children: dialog.fields.invoice }), invoicesQuery.isLoading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: dialog.loadingInvoices })) : selectableInvoices.length === 0 ? (_jsx("p", { className: "text-sm text-destructive", children: dialog.noInvoices })) : (_jsxs(Select, { value: state.invoiceId, disabled: isEditing, onValueChange: (value) => {
250
+ const showEmptyState = !isEditing && !invoicesQuery.isLoading && selectableInvoices.length === 0;
251
+ return (_jsx(Dialog, { open: open, onOpenChange: onOpenChange, children: _jsxs(DialogContent, { size: "lg", className: "gap-0 p-0", children: [_jsx(DialogHeader, { className: "border-b px-6 py-4", children: _jsx(DialogTitle, { children: isEditing ? dialog.editTitle : dialog.title }) }), showEmptyState ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "px-6 py-6", children: _jsx("p", { className: "text-sm text-muted-foreground", children: dialog.noInvoices }) }), _jsx("div", { className: "flex items-center justify-end gap-2 px-6 pb-6", children: _jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), children: messages.common.cancel }) })] })) : (_jsxs("form", { onSubmit: handleSubmit, children: [_jsxs("div", { className: "flex flex-col gap-5 px-6 py-5", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-invoice", children: dialog.fields.invoice }), invoicesQuery.isLoading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: dialog.loadingInvoices })) : (_jsxs(Select, { value: state.invoiceId, disabled: isEditing, onValueChange: (value) => {
211
252
  const id = value ?? "";
212
253
  const next = invoices.find((inv) => inv.id === id);
213
254
  setState((prev) => ({
@@ -223,12 +264,7 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
223
264
  status: inv.status,
224
265
  balance: formatAmount(inv.balanceDueCents),
225
266
  currency: inv.currency,
226
- }) }, inv.id))) })] })), selectedInvoice ? (_jsx("p", { className: "text-xs text-muted-foreground", children: formatMessage(dialog.invoiceMeta, {
227
- total: formatAmount(selectedInvoice.totalCents),
228
- paid: formatAmount(selectedInvoice.paidCents),
229
- due: formatAmount(selectedInvoice.balanceDueCents),
230
- currency: selectedInvoice.currency,
231
- }) })) : null] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-amount", children: dialog.fields.amountCents }), _jsx(CurrencyInput, { id: "record-amount", value: state.amountCents, onChange: (next) => set("amountCents", next ?? 0), currency: state.currency })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.currency }), _jsx(CurrencyCombobox, { value: state.currency || null, onChange: (next) => setPaymentCurrency(next ?? selectedInvoice?.currency ?? defaultCurrency), placeholder: dialog.placeholders.currency })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-date", children: dialog.fields.paymentDate }), _jsx(DatePicker, { value: state.paymentDate || null, onChange: (next) => set("paymentDate", next ?? ""), className: "w-full" })] })] }), isCrossCurrency ? (_jsxs("div", { className: "rounded-md border bg-muted/20 p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h3", { className: "text-sm font-medium", children: dialog.fx.title }), fxRateQuery.isFetching ? (_jsx("p", { className: "text-xs text-muted-foreground", children: dialog.fx.loadingRate })) : autoRateUnavailable ? (_jsx("p", { className: "text-xs text-destructive", children: formatMessage(dialog.fx.rateUnavailable, {
267
+ }) }, inv.id))) })] }))] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-amount", children: dialog.fields.amountCents }), _jsx(CurrencyInput, { id: "record-amount", value: state.amountCents, onChange: (next) => set("amountCents", next ?? 0), currency: state.currency })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.currency }), _jsx(CurrencyCombobox, { value: state.currency || null, onChange: (next) => setPaymentCurrency(next ?? selectedInvoice?.currency ?? defaultCurrency), placeholder: dialog.placeholders.currency })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-date", children: dialog.fields.paymentDate }), _jsx(DatePicker, { value: state.paymentDate || null, onChange: (next) => set("paymentDate", next ?? ""), className: "w-full" })] })] }), isCrossCurrency ? (_jsxs("div", { className: "rounded-md border bg-muted/20 p-3", children: [_jsxs("div", { className: "flex items-start justify-between gap-3", children: [_jsxs("div", { className: "space-y-1", children: [_jsx("h3", { className: "text-sm font-medium", children: dialog.fx.title }), fxRateQuery.isFetching ? (_jsx("p", { className: "text-xs text-muted-foreground", children: dialog.fx.loadingRate })) : autoRateUnavailable ? (_jsx("p", { className: "text-xs text-destructive", children: formatMessage(dialog.fx.rateUnavailable, {
232
268
  invoiceCurrency,
233
269
  paymentCurrency,
234
270
  }) })) : effectiveFxRate && baseAmountCents > 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-sm", children: formatMessage(dialog.fx.summary, {
@@ -255,8 +291,11 @@ export function RecordBookingPaymentDialog({ open, onOpenChange, bookingId, defa
255
291
  })), inputMode: "decimal", placeholder: formatMessage(dialog.placeholders.fxRate, {
256
292
  invoiceCurrency,
257
293
  paymentCurrency,
258
- }) })] })) : null] })) : null, _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.status }), _jsxs(Select, { value: state.status, onValueChange: (value) => set("status", (value ?? "completed")), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: PAYMENT_STATUSES.map((value) => (_jsx(SelectItem, { value: value, children: statusLabels[value] }, value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.paymentMethod }), _jsxs(Select, { value: state.paymentMethod, onValueChange: (value) => set("paymentMethod", (value ?? "bank_transfer")), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: PAYMENT_METHODS.map((value) => (_jsx(SelectItem, { value: value, children: methodLabels[value] }, value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-ref", children: dialog.fields.referenceNumber }), _jsx(Input, { id: "record-ref", value: state.referenceNumber, onChange: (event) => set("referenceNumber", event.target.value), placeholder: dialog.placeholders.referenceNumber })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-notes", children: dialog.fields.notes }), _jsx(Textarea, { id: "record-notes", value: state.notes, onChange: (event) => set("notes", event.target.value), rows: 3 })] }), submitError ? _jsx("p", { className: "text-sm text-destructive", children: submitError }) : null] }), _jsxs(DialogFooter, { children: [_jsx(Button, { type: "button", variant: "ghost", onClick: () => onOpenChange(false), children: messages.common.cancel }), _jsxs(Button, { type: "submit", disabled: isPending ||
294
+ }) })] })) : null] })) : null, _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.status }), _jsxs(Select, { value: state.status, onValueChange: (value) => set("status", (value ?? "completed")), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: PAYMENT_STATUSES.map((value) => (_jsx(SelectItem, { value: value, children: statusLabels[value] }, value))) })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { children: dialog.fields.paymentMethod }), _jsxs(Select, { value: state.paymentMethod, onValueChange: (value) => set("paymentMethod", (value ?? "bank_transfer")), children: [_jsx(SelectTrigger, { className: "w-full", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: PAYMENT_METHODS.map((value) => (_jsx(SelectItem, { value: value, children: methodLabels[value] }, value))) })] })] })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-ref", children: dialog.fields.referenceNumber }), _jsx(Input, { id: "record-ref", value: state.referenceNumber, onChange: (event) => set("referenceNumber", event.target.value), placeholder: dialog.placeholders.referenceNumber })] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Label, { htmlFor: "record-notes", children: dialog.fields.notes }), _jsx(Textarea, { id: "record-notes", value: state.notes, onChange: (event) => set("notes", event.target.value), rows: 3 })] }), canConvertProformaAfter ? (_jsxs("div", { className: "flex items-start justify-between gap-3 rounded-md border bg-muted/20 px-4 py-3", children: [_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx(Label, { htmlFor: "record-convert-proforma", className: "cursor-pointer", onClick: () => setConvertProformaAfter((v) => !v), children: dialog.fields.convertProformaAfter }), _jsx("p", { className: "text-xs text-muted-foreground", children: dialog.fields.convertProformaAfterHint })] }), _jsx(Switch, { id: "record-convert-proforma", checked: convertProformaAfter, onCheckedChange: (next) => {
295
+ convertProformaTouchedRef.current = true;
296
+ setConvertProformaAfter(next);
297
+ } })] })) : null, submitError ? _jsx("p", { className: "text-sm text-destructive", children: submitError }) : null] }), _jsxs("div", { className: "flex items-center justify-end gap-2 px-6 pb-6", children: [_jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), children: messages.common.cancel }), _jsxs(Button, { type: "submit", disabled: isPending ||
259
298
  !state.invoiceId ||
260
299
  state.amountCents <= 0 ||
261
- (requiresBaseAmount && baseAmountCents <= 0), children: [isPending ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : null, isEditing ? dialog.actions.save : dialog.actions.record] })] })] })] }) }));
300
+ (requiresBaseAmount && baseAmountCents <= 0), children: [isPending ? _jsx(Loader2, { className: "mr-2 h-4 w-4 animate-spin" }) : null, isEditing ? dialog.actions.save : dialog.actions.record] })] })] }))] }) }));
262
301
  }