@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.
- package/dist/components/booking-invoice-dialog.d.ts +44 -0
- package/dist/components/booking-invoice-dialog.d.ts.map +1 -0
- package/dist/components/booking-invoice-dialog.js +349 -0
- package/dist/components/record-booking-payment-dialog.d.ts.map +1 -1
- package/dist/components/record-booking-payment-dialog.js +59 -20
- package/dist/i18n/en.d.ts +35 -0
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +36 -1
- package/dist/i18n/messages.d.ts +47 -0
- package/dist/i18n/messages.d.ts.map +1 -1
- package/dist/i18n/provider.d.ts +70 -0
- package/dist/i18n/provider.d.ts.map +1 -1
- package/dist/i18n/ro.d.ts +35 -0
- package/dist/i18n/ro.d.ts.map +1 -1
- package/dist/i18n/ro.js +36 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/package.json +13 -13
- package/dist/components/booking-invoice-sheet.d.ts +0 -25
- package/dist/components/booking-invoice-sheet.d.ts.map +0 -1
- package/dist/components/booking-invoice-sheet.js +0 -116
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
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,
|
|
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
|
|
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(
|
|
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.
|
|
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-
|
|
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
|
}
|