@flowselections/facturen-installatie-techniek 1.4.3 → 1.5.1
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/README.md +27 -0
- package/dist-lib/components/FacturenForInstallatieTechniek.d.ts.map +1 -1
- package/dist-lib/components/FacturenForInstallatieTechniek.js +101 -25
- package/dist-lib/components/FactuurDetailPage.d.ts.map +1 -1
- package/dist-lib/components/FactuurDetailPage.js +49 -24
- package/dist-lib/components/InvoiceA4Preview.d.ts +3 -0
- package/dist-lib/components/InvoiceA4Preview.d.ts.map +1 -1
- package/dist-lib/components/InvoiceA4Preview.js +43 -8
- package/dist-lib/components/settings/InvoiceModuleSettingsCard.d.ts +2 -0
- package/dist-lib/components/settings/InvoiceModuleSettingsCard.d.ts.map +1 -0
- package/dist-lib/components/settings/InvoiceModuleSettingsCard.js +53 -0
- package/dist-lib/components/settings/ModuleHealthSettingsCard.d.ts.map +1 -1
- package/dist-lib/components/settings/ModuleHealthSettingsCard.js +2 -0
- package/dist-lib/index.d.ts +2 -0
- package/dist-lib/index.d.ts.map +1 -1
- package/dist-lib/index.js +5 -2
- package/dist-lib/integrations/supabase/types.d.ts +10550 -213
- package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.js +1 -0
- package/dist-lib/lib/config.d.ts +0 -4
- package/dist-lib/lib/config.d.ts.map +1 -1
- package/dist-lib/lib/config.js +2 -4
- package/dist-lib/lib/dependencies.d.ts +2 -0
- package/dist-lib/lib/dependencies.d.ts.map +1 -1
- package/dist-lib/lib/dependencies.js +19 -0
- package/dist-lib/lib/facturen.d.ts +74 -5
- package/dist-lib/lib/facturen.d.ts.map +1 -1
- package/dist-lib/lib/facturen.js +195 -43
- package/dist-lib/lib/rekenregels.d.ts +49 -0
- package/dist-lib/lib/rekenregels.d.ts.map +1 -0
- package/dist-lib/lib/rekenregels.js +135 -0
- package/dist-lib/lib/settings.d.ts +32 -0
- package/dist-lib/lib/settings.d.ts.map +1 -0
- package/dist-lib/lib/settings.js +117 -0
- package/package.json +2 -2
package/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# @flowselections/facturen-installatie-techniek
|
|
2
|
+
|
|
3
|
+
Facturen-module voor FlowSelections-shells: facturen maken uit werkbonnen/offertes of handmatig, bewerken, samenvoegen, als PDF versturen.
|
|
4
|
+
|
|
5
|
+
## Instellingen (per database)
|
|
6
|
+
- **Btw-tarieven** — tabel `invoice_vat_rates` (tarief, omschrijving, standaard, actief). Standaard 21% (standaard) en 9%. "Btw verlegd" (0%) is altijd een aparte keuze. Beheer: Instellingen → Btw en tijdzone.
|
|
7
|
+
- **Tijdzone** — tabel `invoice_module_settings.timezone`, standaard `Europe/Amsterdam`. Bepaalt "vandaag" voor de factuurdatum en de week/maand/jaar-periodes.
|
|
8
|
+
- Ontbreken deze tabellen of rijen, dan gebruikt de module de standaardwaarden hierboven en logt een waarschuwing (`[facturen] instellingen ontbreken…`); de controlekaart toont dit ook.
|
|
9
|
+
|
|
10
|
+
## Rekenregels
|
|
11
|
+
`src/lib/rekenregels.ts` bevat de gedeelde FlowSelections-rekenregels (bedragen naar centen, kalenderdatums, pakket-, reis-, afrondings- en mandaatregels). Deze module gebruikt `parseBedragNaarCenten`, `vandaagLokaal`, `plusDagen` en `lokaleDatum`; basispakketten rekenen nog met de bestaande factuurlogica.
|
|
12
|
+
|
|
13
|
+
## Gedrag
|
|
14
|
+
- Bedragen en aantallen: "12,50", "12.50", "1.250,00" en "1,250.00" worden herkend; ongeldige invoer geeft "Ongeldig bedrag" en blokkeert opslaan.
|
|
15
|
+
- Per bron (werkbon/offerte) bestaat maximaal één niet-gecrediteerde factuur (unieke index + controle in `invoice_create_from_source`).
|
|
16
|
+
- Samenvoegen alleen bij dezelfde klant (klant-id) en hetzelfde btw-regime; ook in `invoice_merge` gecontroleerd.
|
|
17
|
+
- Opslaan controleert of een ander intussen wijzigde (`invoice_update_full_vat`, optionele `_expected_updated_at`).
|
|
18
|
+
- Lange facturen worden meerdere A4-pagina's met kopregel en paginanummer. De PDF wordt alleen gedownload via de knop "PDF downloaden".
|
|
19
|
+
- Verzenden naar een ander adres wijzigt het klantrecord niet.
|
|
20
|
+
|
|
21
|
+
## Vereist in de omgeving
|
|
22
|
+
- Shell-sessie en Supabase-client via `@flowselections/core`.
|
|
23
|
+
- Opslagbuckets `layout-logos` en `artikel-fotos`.
|
|
24
|
+
- Server: `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` (door de shell geleverd).
|
|
25
|
+
|
|
26
|
+
## Tests
|
|
27
|
+
`bunx vitest@3 run`
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FacturenForInstallatieTechniek.d.ts","sourceRoot":"","sources":["../../src/components/FacturenForInstallatieTechniek.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"FacturenForInstallatieTechniek.d.ts","sourceRoot":"","sources":["../../src/components/FacturenForInstallatieTechniek.tsx"],"names":[],"mappings":"AAqLA,wBAAgB,8BAA8B,gCAwjB7C"}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { WerkbonPreviewPanel } from "./WerkbonPreviewPanel.js";
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
-
import { dependencyMessage } from "../lib/dependencies";
|
|
5
|
-
import {
|
|
4
|
+
import { dependencyMessage, explainError } from "../lib/dependencies";
|
|
5
|
+
import { plusDagen, vandaagLokaal } from "../lib/rekenregels";
|
|
6
|
+
import { useInvoiceSettings } from "../lib/settings";
|
|
6
7
|
import { supabase } from "@flowselections/core";
|
|
7
8
|
import { Link, useNavigate } from "@tanstack/react-router";
|
|
8
9
|
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Tabs, TabsContent, TabsList, TabsTrigger, toast, } from "@flowselections/core";
|
|
9
10
|
import { Plus } from "lucide-react";
|
|
10
|
-
import { createInvoiceFromSource, emptyInvoiceDetails, fetchInvoicesDetailed, fetchInvoiceKpis, fetchSourceLines, formatCents, listSources, SOURCE_LABELS, STATUS_LABELS, emptyInvoiceBranding, fetchInvoiceBranding, getInvoiceLogoUrl, editLineToBillable, emptyEditLine, peekNextInvoiceNumber, } from "../lib/facturen";
|
|
11
|
+
import { createInvoiceFromSource, emptyInvoiceDetails, firstLineInputError, mergeBlockReason, fetchInvoicesDetailed, fetchInvoiceKpis, fetchSourceLines, formatCents, listSources, SOURCE_LABELS, STATUS_LABELS, emptyInvoiceBranding, fetchInvoiceBranding, getInvoiceLogoUrl, editLineToBillable, emptyEditLine, peekNextInvoiceNumber, canMerge, canSelectForMerge, mergeInvoices, } from "../lib/facturen";
|
|
11
12
|
import { InvoiceA4Preview } from "./InvoiceA4Preview";
|
|
12
13
|
function statusVariant(status) {
|
|
13
14
|
if (status === "betaald")
|
|
@@ -18,13 +19,24 @@ function statusVariant(status) {
|
|
|
18
19
|
return "outline";
|
|
19
20
|
return "secondary";
|
|
20
21
|
}
|
|
21
|
-
function InvoiceTable({ invoices, emptyText, onOpen, }) {
|
|
22
|
+
function InvoiceTable({ invoices, emptyText, onOpen, selection, }) {
|
|
22
23
|
if (invoices.length === 0) {
|
|
23
24
|
return _jsx("p", { className: "text-sm text-muted-foreground", children: emptyText });
|
|
24
25
|
}
|
|
25
|
-
return (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Nummer" }), _jsx(TableHead, { children: "Klant" }), _jsx(TableHead, { children: "Bron" }), _jsx(TableHead, { children: "Datum" }), _jsx(TableHead, { className: "text-right", children: "Totaal" }), _jsx(TableHead, { children: "Status" })] }) }), _jsx(TableBody, { children: invoices.map((invoice) =>
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
return (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [selection && _jsx(TableHead, { className: "w-10" }), _jsx(TableHead, { children: "Nummer" }), _jsx(TableHead, { children: "Klant" }), _jsx(TableHead, { children: "Bron" }), _jsx(TableHead, { children: "Datum" }), _jsx(TableHead, { className: "text-right", children: "Totaal" }), _jsx(TableHead, { children: "Status" })] }) }), _jsx(TableBody, { children: invoices.map((invoice) => {
|
|
27
|
+
const checked = selection?.selectedIds.has(invoice.id) ?? false;
|
|
28
|
+
const selectable = selection ? checked || selection.isSelectable(invoice) : true;
|
|
29
|
+
return (_jsxs(TableRow, { className: selection && !selectable ? "cursor-not-allowed opacity-40" : "cursor-pointer", onClick: () => {
|
|
30
|
+
if (selection) {
|
|
31
|
+
if (selectable)
|
|
32
|
+
selection.onToggle(invoice);
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
onOpen(invoice.id);
|
|
36
|
+
}, children: [selection && (_jsx(TableCell, { onClick: (e) => e.stopPropagation(), children: _jsx("input", { type: "checkbox", "aria-label": `Selecteer ${invoice.invoice_number ?? "factuur"}`, className: "h-4 w-4 accent-primary", checked: checked, disabled: !selectable, onChange: () => selection.onToggle(invoice) }) })), _jsx(TableCell, { className: "font-medium", children: selection ? (invoice.invoice_number ?? "—") : (_jsx(Link, { to: "/facturen-installatietechniek/detail/$id", params: { id: invoice.id }, search: { send: false }, className: "underline-offset-2 hover:underline", children: invoice.invoice_number ?? "—" })) }), _jsx(TableCell, { children: invoice.customer_name ?? "—" }), _jsx(TableCell, { children: _jsx(Badge, { variant: "outline", children: SOURCE_LABELS[invoice.source_type]
|
|
37
|
+
? SOURCE_LABELS[invoice.source_type] + (invoice.source_number ? ` ${invoice.source_number}` : "")
|
|
38
|
+
: "—" }) }), _jsx(TableCell, { children: invoice.invoice_date ? new Date(`${invoice.invoice_date}T12:00:00`).toLocaleDateString("nl-NL") : "—" }), _jsx(TableCell, { className: "text-right", children: formatCents(invoice.total_cents) }), _jsx(TableCell, { children: _jsx(Badge, { variant: statusVariant(invoice.status), children: STATUS_LABELS[invoice.status] ?? invoice.status }) })] }, invoice.id));
|
|
39
|
+
}) })] }));
|
|
28
40
|
}
|
|
29
41
|
export function FacturenForInstallatieTechniek() {
|
|
30
42
|
const navigate = useNavigate();
|
|
@@ -41,6 +53,46 @@ export function FacturenForInstallatieTechniek() {
|
|
|
41
53
|
const [open, setOpen] = useState(false);
|
|
42
54
|
const [saving, setSaving] = useState(false);
|
|
43
55
|
const createRef = useRef(false);
|
|
56
|
+
// samenvoegen
|
|
57
|
+
const [mergeMode, setMergeMode] = useState(false);
|
|
58
|
+
const [mergeSelected, setMergeSelected] = useState([]);
|
|
59
|
+
const [mergeConfirm, setMergeConfirm] = useState(false);
|
|
60
|
+
const [merging, setMerging] = useState(false);
|
|
61
|
+
const mergeRef = useRef(false);
|
|
62
|
+
const mergeIds = useMemo(() => new Set(mergeSelected.map((i) => i.id)), [mergeSelected]);
|
|
63
|
+
const stopMerge = () => { setMergeMode(false); setMergeSelected([]); setMergeConfirm(false); };
|
|
64
|
+
const toggleMerge = (invoice) => {
|
|
65
|
+
if (!mergeSelected.some((i) => i.id === invoice.id)) {
|
|
66
|
+
const reason = mergeBlockReason(invoice, mergeSelected);
|
|
67
|
+
if (reason) {
|
|
68
|
+
toast.error(`Niet samen te voegen: ${reason}`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
setMergeSelected((cur) => cur.some((i) => i.id === invoice.id)
|
|
73
|
+
? cur.filter((i) => i.id !== invoice.id)
|
|
74
|
+
: canSelectForMerge(invoice, cur) ? [...cur, invoice] : cur);
|
|
75
|
+
};
|
|
76
|
+
const doMerge = async () => {
|
|
77
|
+
if (!canMerge(mergeSelected) || mergeRef.current)
|
|
78
|
+
return;
|
|
79
|
+
mergeRef.current = true;
|
|
80
|
+
setMerging(true);
|
|
81
|
+
try {
|
|
82
|
+
const id = await mergeInvoices(mergeSelected.map((i) => i.id));
|
|
83
|
+
toast.success(`${mergeSelected.length} facturen samengevoegd`);
|
|
84
|
+
stopMerge();
|
|
85
|
+
await navigate({ to: "/facturen-installatietechniek/detail/$id", params: { id }, search: { send: true } });
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
console.error("Samenvoegen mislukt", e);
|
|
89
|
+
toast.error(`Samenvoegen mislukt: ${explainError(e)}`);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
mergeRef.current = false;
|
|
93
|
+
setMerging(false);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
44
96
|
// bron-tab
|
|
45
97
|
const [sourceType, setSourceType] = useState("work_order");
|
|
46
98
|
const [sources, setSources] = useState([]);
|
|
@@ -50,7 +102,9 @@ export function FacturenForInstallatieTechniek() {
|
|
|
50
102
|
const [pickedCustomerId, setPickedCustomerId] = useState(null);
|
|
51
103
|
const [tab, setTab] = useState("handmatig");
|
|
52
104
|
const [details, setDetails] = useState(emptyInvoiceDetails);
|
|
53
|
-
const
|
|
105
|
+
const settings = useInvoiceSettings();
|
|
106
|
+
const [vatRate, setVatRate] = useState(String(settings.defaultVatRate));
|
|
107
|
+
useEffect(() => { setVatRate(String(settings.defaultVatRate)); }, [settings.defaultVatRate]);
|
|
54
108
|
const [lines, setLines] = useState([emptyEditLine()]);
|
|
55
109
|
const [nextNumber, setNextNumber] = useState(null);
|
|
56
110
|
const [linkedWorkOrder, setLinkedWorkOrder] = useState(null);
|
|
@@ -157,26 +211,27 @@ export function FacturenForInstallatieTechniek() {
|
|
|
157
211
|
search: { send: false },
|
|
158
212
|
}), [navigate]);
|
|
159
213
|
const kpi = useMemo(() => {
|
|
160
|
-
|
|
214
|
+
// Kalenderdatums (yyyy-mm-dd) in de tijdzone uit de instellingen.
|
|
215
|
+
const today = vandaagLokaal(settings.timezone);
|
|
216
|
+
const weekday = (new Date(`${today}T00:00:00Z`).getUTCDay() + 6) % 7; // ma = 0
|
|
217
|
+
const weekStart = plusDagen(today, -weekday);
|
|
218
|
+
const weekEnd = plusDagen(weekStart, 7);
|
|
161
219
|
const inPeriod = (d) => {
|
|
220
|
+
if (!d)
|
|
221
|
+
return false;
|
|
162
222
|
if (paidPeriod === "jaar")
|
|
163
|
-
return d.
|
|
223
|
+
return d.slice(0, 4) === today.slice(0, 4);
|
|
164
224
|
if (paidPeriod === "maand")
|
|
165
|
-
return d.
|
|
166
|
-
|
|
167
|
-
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
168
|
-
start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
|
|
169
|
-
const end = new Date(start);
|
|
170
|
-
end.setDate(end.getDate() + 7);
|
|
171
|
-
return d >= start && d < end;
|
|
225
|
+
return d.slice(0, 7) === today.slice(0, 7);
|
|
226
|
+
return d >= weekStart && d < weekEnd;
|
|
172
227
|
};
|
|
173
228
|
const paidRows = Array.isArray(kpiData?.paidThisYear) ? kpiData.paidThisYear : [];
|
|
174
229
|
const paidMonthCents = paidRows
|
|
175
|
-
.filter((i) => inPeriod(
|
|
230
|
+
.filter((i) => inPeriod(String(i.invoice_date ?? "").slice(0, 10)))
|
|
176
231
|
.reduce((sum, i) => sum + (Number(i.total_cents) || 0), 0);
|
|
177
232
|
const openCount = kpiData?.openCount ?? 0;
|
|
178
233
|
return { paidMonthCents, openCount };
|
|
179
|
-
}, [kpiData, paidPeriod]);
|
|
234
|
+
}, [kpiData, paidPeriod, settings.timezone]);
|
|
180
235
|
const createFromSource = async () => {
|
|
181
236
|
const source = sources.find((s) => s.id === selectedSource);
|
|
182
237
|
if (!source) {
|
|
@@ -188,6 +243,11 @@ export function FacturenForInstallatieTechniek() {
|
|
|
188
243
|
createRef.current = true;
|
|
189
244
|
setSaving(true);
|
|
190
245
|
try {
|
|
246
|
+
const { data: sess } = await supabase.auth.getSession();
|
|
247
|
+
if (!sess?.session) {
|
|
248
|
+
toast.error("Factuur aanmaken mislukt: Je bent niet (meer) ingelogd. Log opnieuw in.");
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
191
251
|
const sourceLines = await fetchSourceLines(sourceType, source.id);
|
|
192
252
|
if (sourceLines.length === 0) {
|
|
193
253
|
toast.error("Deze bron heeft geen factureerbare regels");
|
|
@@ -205,8 +265,9 @@ export function FacturenForInstallatieTechniek() {
|
|
|
205
265
|
setOpen(false);
|
|
206
266
|
await load();
|
|
207
267
|
}
|
|
208
|
-
catch {
|
|
209
|
-
|
|
268
|
+
catch (e) {
|
|
269
|
+
console.error("Factuur aanmaken mislukt", e);
|
|
270
|
+
toast.error(`Factuur aanmaken mislukt: ${explainError(e)}`);
|
|
210
271
|
}
|
|
211
272
|
finally {
|
|
212
273
|
createRef.current = false;
|
|
@@ -214,6 +275,11 @@ export function FacturenForInstallatieTechniek() {
|
|
|
214
275
|
}
|
|
215
276
|
};
|
|
216
277
|
const createManual = async () => {
|
|
278
|
+
const invalid = firstLineInputError(lines);
|
|
279
|
+
if (invalid) {
|
|
280
|
+
toast.error(invalid);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
217
283
|
const payloadLines = lines.filter((l) => l.description.trim() !== "").map(editLineToBillable);
|
|
218
284
|
if (!details.customer_name.trim() || payloadLines.length === 0) {
|
|
219
285
|
toast.error("Vul een klantnaam en minimaal één regel in");
|
|
@@ -224,11 +290,16 @@ export function FacturenForInstallatieTechniek() {
|
|
|
224
290
|
createRef.current = true;
|
|
225
291
|
setSaving(true);
|
|
226
292
|
try {
|
|
293
|
+
const { data: sess } = await supabase.auth.getSession();
|
|
294
|
+
if (!sess?.session) {
|
|
295
|
+
toast.error("Factuur aanmaken mislukt: Je bent niet (meer) ingelogd. Log opnieuw in.");
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
227
298
|
const result = await createInvoiceFromSource({
|
|
228
299
|
sourceType: linkedWorkOrder ? "work_order" : "manual",
|
|
229
300
|
sourceId: linkedWorkOrder?.id ?? null,
|
|
230
301
|
customerId: linkedWorkOrder?.customer_id ?? pickedCustomerId ?? null,
|
|
231
|
-
vatRate:
|
|
302
|
+
vatRate: Number(vatRate.replace(",", ".")) || settings.defaultVatRate,
|
|
232
303
|
lines: payloadLines,
|
|
233
304
|
details,
|
|
234
305
|
});
|
|
@@ -241,15 +312,20 @@ export function FacturenForInstallatieTechniek() {
|
|
|
241
312
|
search: { send: true },
|
|
242
313
|
});
|
|
243
314
|
}
|
|
244
|
-
catch {
|
|
245
|
-
|
|
315
|
+
catch (e) {
|
|
316
|
+
console.error("Factuur aanmaken mislukt", e);
|
|
317
|
+
toast.error(`Factuur aanmaken mislukt: ${explainError(e)}`);
|
|
246
318
|
}
|
|
247
319
|
finally {
|
|
248
320
|
createRef.current = false;
|
|
249
321
|
setSaving(false);
|
|
250
322
|
}
|
|
251
323
|
};
|
|
252
|
-
return (_jsxs("main", { className: "space-y-6 p-6", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: paidPeriod === "week" ? "Betaald deze week" : paidPeriod === "jaar" ? "Betaald dit jaar" : "Betaald deze maand" }), _jsxs(Select, { value: paidPeriod, onValueChange: (v) => setPaidPeriod(v), children: [_jsx(SelectTrigger, { className: "h-7 w-24 text-xs", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "week", children: "Week" }), _jsx(SelectItem, { value: "maand", children: "Maand" }), _jsx(SelectItem, { value: "jaar", children: "Jaar" })] })] })] }) }), _jsx(CardContent, { className: "text-2xl font-semibold", children: formatCents(kpi.paidMonthCents) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: "Openstaand" }) }), _jsx(CardContent, { className: "text-2xl font-semibold", children: kpi.openCount })] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsxs(Select, { value: statusFilter, onValueChange: setStatusFilter, children: [_jsx(SelectTrigger, { className: "w-44", children: _jsx(SelectValue, { placeholder: "Status" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "alle", children: "Alle statussen" }), Object.entries(STATUS_LABELS).map(([value, label]) => (_jsx(SelectItem, { value: value, children: label }, value)))] })] }), _jsxs(Select, { value: sourceFilter, onValueChange: setSourceFilter, children: [_jsx(SelectTrigger, { className: "w-44", children: _jsx(SelectValue, { placeholder: "Bron" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "alle", children: "Alle bronnen" }), Object.entries(SOURCE_LABELS).map(([value, label]) => (_jsx(SelectItem, { value: value, children: label }, value)))] })] }),
|
|
324
|
+
return (_jsxs("main", { className: "space-y-6 p-6", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: paidPeriod === "week" ? "Betaald deze week" : paidPeriod === "jaar" ? "Betaald dit jaar" : "Betaald deze maand" }), _jsxs(Select, { value: paidPeriod, onValueChange: (v) => setPaidPeriod(v), children: [_jsx(SelectTrigger, { className: "h-7 w-24 text-xs", children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "week", children: "Week" }), _jsx(SelectItem, { value: "maand", children: "Maand" }), _jsx(SelectItem, { value: "jaar", children: "Jaar" })] })] })] }) }), _jsx(CardContent, { className: "text-2xl font-semibold", children: formatCents(kpi.paidMonthCents) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: "Openstaand" }) }), _jsx(CardContent, { className: "text-2xl font-semibold", children: kpi.openCount })] })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-3", children: [_jsxs(Select, { value: statusFilter, onValueChange: setStatusFilter, children: [_jsx(SelectTrigger, { className: "w-44", children: _jsx(SelectValue, { placeholder: "Status" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "alle", children: "Alle statussen" }), Object.entries(STATUS_LABELS).filter(([value]) => value !== "samengevoegd").map(([value, label]) => (_jsx(SelectItem, { value: value, children: label }, value)))] })] }), _jsxs(Select, { value: sourceFilter, onValueChange: setSourceFilter, children: [_jsx(SelectTrigger, { className: "w-44", children: _jsx(SelectValue, { placeholder: "Bron" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "alle", children: "Alle bronnen" }), Object.entries(SOURCE_LABELS).map(([value, label]) => (_jsx(SelectItem, { value: value, children: label }, value)))] })] }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [mergeMode ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "outline", onClick: stopMerge, disabled: merging, children: "Annuleren" }), _jsxs(Button, { onClick: () => setMergeConfirm(true), disabled: !canMerge(mergeSelected) || merging, children: ["Samenvoegen (", mergeSelected.length, ")"] })] })) : (_jsx(Button, { variant: "outline", onClick: () => setMergeMode(true), children: "Samenvoegen" })), _jsxs(Button, { onClick: openNew, children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Nieuwe factuur"] })] })] }), loading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "Laden\u2026" })) : sessionExpired ? (_jsx(Card, { children: _jsxs(CardContent, { className: "flex flex-col items-start gap-3 py-6", children: [_jsx("p", { className: "text-sm", children: "Je sessie is verlopen, log opnieuw in om je facturen te zien." }), _jsx(Button, { asChild: true, children: _jsx(Link, { to: "/login", children: "Opnieuw inloggen" }) })] }) })) : loadError ? (_jsx(Card, { children: _jsxs(CardContent, { className: "flex flex-col items-start gap-3 py-6", children: [_jsx("p", { className: "text-sm", children: "Facturen konden niet worden geladen. Probeer het opnieuw." }), _jsx(Button, { onClick: () => void load(), children: "Opnieuw proberen" })] }) })) : (_jsxs(_Fragment, { children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Goedkeuren en verzenden" }) }), _jsxs(CardContent, { children: [_jsx(InvoiceTable, { invoices: conceptInvoices, emptyText: "Geen conceptfacturen.", onOpen: openInvoice, selection: mergeMode ? {
|
|
325
|
+
selectedIds: mergeIds,
|
|
326
|
+
isSelectable: (i) => canSelectForMerge(i, mergeSelected),
|
|
327
|
+
onToggle: toggleMerge,
|
|
328
|
+
} : undefined }), mergeMode && (_jsx("p", { className: "mt-3 text-xs text-muted-foreground", children: "Vink minimaal twee conceptfacturen van dezelfde klant aan." }))] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Facturen" }) }), _jsxs(CardContent, { children: [_jsx(InvoiceTable, { invoices: sendOrPaidInvoices, emptyText: "Geen facturen gevonden.", onOpen: openInvoice }), hasMore && (_jsx("div", { className: "mt-4 flex justify-center", children: _jsx(Button, { variant: "outline", onClick: () => void loadMore(), disabled: loadingMore, children: loadingMore ? "Laden…" : "Meer laden" }) }))] })] })] })), _jsx(Dialog, { open: mergeConfirm, onOpenChange: (o) => !merging && setMergeConfirm(o), children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Facturen samenvoegen" }) }), _jsxs("p", { className: "text-sm", children: [mergeSelected.length, " conceptfacturen van ", mergeSelected[0]?.customer_name ?? "deze klant", " worden samengevoegd tot \u00E9\u00E9n nieuwe factuur. De losse facturen worden bewaard als \u201CSamengevoegd\u201D en verdwijnen uit de lijst."] }), _jsx("ul", { className: "text-sm text-muted-foreground", children: mergeSelected.map((i) => (_jsxs("li", { children: [i.invoice_number ?? "—", " \u00B7 ", formatCents(i.total_cents)] }, i.id))) }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "outline", onClick: () => setMergeConfirm(false), disabled: merging, children: "Annuleren" }), _jsx(Button, { onClick: () => void doMerge(), disabled: merging, children: merging ? "Bezig…" : "Samenvoegen" })] })] }) }), _jsx(Dialog, { open: open, onOpenChange: setOpen, children: _jsxs(DialogContent, { className: "flex max-h-[92vh] max-w-[calc(100vw-2rem)] flex-col sm:max-w-7xl", children: [_jsx(DialogHeader, { className: "shrink-0", children: _jsx(DialogTitle, { children: "Nieuwe factuur" }) }), _jsxs("div", { className: linkedWorkOrder ? "grid min-h-0 flex-1 gap-6 overflow-y-auto md:grid-cols-2 md:overflow-hidden" : "contents", children: [linkedWorkOrder && (_jsx("div", { className: "min-h-0 rounded-md border bg-muted/30 p-4 md:overflow-y-auto", children: _jsx(WerkbonPreviewPanel, { workOrderId: linkedWorkOrder.id }) })), _jsxs("div", { className: "min-h-0 flex-1 overflow-y-auto pr-1", children: [linkedWorkOrder && _jsx("p", { className: "mb-2 text-xs uppercase tracking-wide text-muted-foreground", children: "Factuur" }), _jsxs(Tabs, { value: tab, onValueChange: setTab, children: [_jsxs(TabsList, { children: [_jsx(TabsTrigger, { value: "bron", children: "Uit bron" }), _jsx(TabsTrigger, { value: "handmatig", children: "Handmatig" })] }), _jsxs(TabsContent, { value: "bron", className: "space-y-4 pt-4", children: [_jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "Brontype" }), _jsxs(Select, { value: sourceType, onValueChange: (v) => setSourceType(v), children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, {}) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "work_order", children: "Werkbon" }), _jsx(SelectItem, { value: "quotation", children: "Offerte" })] })] })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "Bron" }), sourcesLoading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "Bronnen laden\u2026" })) : sources.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "Geen factureerbare bronnen gevonden." })) : (_jsxs(Select, { value: selectedSource, onValueChange: setSelectedSource, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, { placeholder: "Kies een bron" }) }), _jsx(SelectContent, { children: sources.map((s) => (_jsx(SelectItem, { value: s.id, children: (s.number ?? "zonder nummer") + (s.customer_name ? ` — ${s.customer_name}` : "") }, s.id))) })] }))] }), _jsx(Button, { onClick: createFromSource, disabled: saving || !selectedSource, className: "w-full", children: saving ? "Bezig…" : "Factuur aanmaken" })] }), _jsxs(TabsContent, { value: "handmatig", className: "space-y-4 pt-4", children: [linkedWorkOrder ? (_jsxs("p", { className: "text-sm text-muted-foreground", children: ["Gekoppeld aan werkbon ", _jsx("span", { className: "font-medium", children: linkedWorkOrder.number ?? "—" })] })) : null, _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Label, { htmlFor: "btw", className: "text-xs", children: "Standaard btw % nieuwe regels" }), _jsx(Input, { id: "btw", className: "h-8 w-20", value: vatRate, onChange: (e) => setVatRate(e.target.value) })] }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Klik op de teksten in de factuur om ze aan te passen." }), _jsx(InvoiceA4Preview, { branding: branding, details: details, lines: lines, invoiceNumber: nextNumber, numberIsProvisional: true, logoUrl: logoUrl, printable: false, editable: true, defaultVat: vatRate, onDetailsChange: setDetails, onLinesChange: setLines, onCustomerPick: linkedWorkOrder ? undefined : (c) => {
|
|
253
329
|
setPickedCustomerId(c.id);
|
|
254
330
|
setDetails((d) => ({
|
|
255
331
|
...d,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"FactuurDetailPage.d.ts","sourceRoot":"","sources":["../../src/components/FactuurDetailPage.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"FactuurDetailPage.d.ts","sourceRoot":"","sources":["../../src/components/FactuurDetailPage.tsx"],"names":[],"mappings":"AAgDA,wBAAgB,iBAAiB,gCAudhC"}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { useInvoiceSettings } from "../lib/settings";
|
|
4
4
|
import { Link, useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
|
5
|
-
import { Badge, Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Label,
|
|
5
|
+
import { Badge, Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Label, Textarea, toast, } from "@flowselections/core";
|
|
6
6
|
import { ArrowLeft, ChevronLeft, ChevronRight, Download, FileCheck2, Mail, Pencil, Save, X } from "lucide-react";
|
|
7
|
-
import { detailsFromRecord, downloadPdfBase64, elementToPdfBase64, emptyInvoiceBranding, emptyInvoiceDetails, fetchCustomerEmail, fetchInvoice, fetchInvoiceEmailPurpose, fetchSenderForPurpose, fetchInvoiceItems, fetchInvoices, getInvoiceLogoUrl, itemToEditLine, registerInvoiceSend, sendInvoiceEmail, SOURCE_LABELS, STATUS_LABELS, updateInvoiceFull,
|
|
7
|
+
import { detailsFromRecord, downloadPdfBase64, elementToPdfBase64, emptyInvoiceBranding, emptyInvoiceDetails, fetchCustomerEmail, fetchInvoice, fetchInvoiceEmailPurpose, fetchSenderForPurpose, fetchInvoiceItems, fetchInvoices, getInvoiceLogoUrl, itemToEditLine, registerInvoiceSend, sendInvoiceEmail, setInvoiceNote, SOURCE_LABELS, STATUS_LABELS, updateInvoiceFull, firstLineInputError, InvoiceConflictError, } from "../lib/facturen";
|
|
8
8
|
import { InvoiceA4Preview } from "./InvoiceA4Preview";
|
|
9
9
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
10
10
|
const isValidEmail = (value) => EMAIL_PATTERN.test(value.trim());
|
|
@@ -34,8 +34,14 @@ export function FactuurDetailPage() {
|
|
|
34
34
|
const sheetRef = useRef(null);
|
|
35
35
|
const autoOpenedRef = useRef(false);
|
|
36
36
|
const sendingRef = useRef(false);
|
|
37
|
-
const statusRef = useRef(false);
|
|
38
37
|
const [senderChecked, setSenderChecked] = useState(false);
|
|
38
|
+
const [noteText, setNoteText] = useState("");
|
|
39
|
+
const [reverseCharge, setReverseCharge] = useState(false);
|
|
40
|
+
const [noteSaving, setNoteSaving] = useState(false);
|
|
41
|
+
const [noteSavedAt, setNoteSavedAt] = useState(null);
|
|
42
|
+
const settings = useInvoiceSettings();
|
|
43
|
+
const settingsRef = useRef(settings);
|
|
44
|
+
settingsRef.current = settings;
|
|
39
45
|
const loadSeqRef = useRef(0);
|
|
40
46
|
const load = useCallback(async () => {
|
|
41
47
|
const seq = ++loadSeqRef.current;
|
|
@@ -48,9 +54,12 @@ export function FactuurDetailPage() {
|
|
|
48
54
|
if (seq !== loadSeqRef.current)
|
|
49
55
|
return;
|
|
50
56
|
setInvoice(inv);
|
|
57
|
+
setNoteText(inv?.note ?? "");
|
|
58
|
+
setReverseCharge(Boolean(inv?.vat_reverse_charge));
|
|
59
|
+
setNoteSavedAt(null);
|
|
51
60
|
setDetails(detailsFromRecord(inv));
|
|
52
61
|
const safeRows = Array.isArray(rows) ? rows : [];
|
|
53
|
-
setLines(safeRows.map((r) => itemToEditLine(r, Number(inv?.vat_rate ??
|
|
62
|
+
setLines(safeRows.map((r) => itemToEditLine(r, Number(inv?.vat_rate ?? settingsRef.current.defaultVatRate))));
|
|
54
63
|
setLogoUrl(logo);
|
|
55
64
|
}
|
|
56
65
|
catch {
|
|
@@ -105,37 +114,49 @@ export function FactuurDetailPage() {
|
|
|
105
114
|
search: { send: false },
|
|
106
115
|
});
|
|
107
116
|
};
|
|
108
|
-
const
|
|
109
|
-
|
|
117
|
+
const saveRef = useRef(false);
|
|
118
|
+
const lineError = firstLineInputError(lines);
|
|
119
|
+
const save = async () => {
|
|
120
|
+
if (saveRef.current)
|
|
121
|
+
return;
|
|
122
|
+
if (lineError) {
|
|
123
|
+
toast.error(`Kan niet opslaan: ${lineError}`);
|
|
110
124
|
return;
|
|
111
|
-
|
|
125
|
+
}
|
|
126
|
+
saveRef.current = true;
|
|
112
127
|
setSaving(true);
|
|
113
128
|
try {
|
|
114
|
-
await
|
|
115
|
-
toast.success("
|
|
129
|
+
await updateInvoiceFull(id, details, lines, invoice?.note ?? null, reverseCharge, invoice?.updated_at ?? null);
|
|
130
|
+
toast.success("Factuur opgeslagen");
|
|
131
|
+
setEditing(false);
|
|
116
132
|
await load();
|
|
117
133
|
}
|
|
118
|
-
catch {
|
|
119
|
-
|
|
134
|
+
catch (e) {
|
|
135
|
+
console.error("[facturen] opslaan mislukt", e);
|
|
136
|
+
toast.error(e instanceof InvoiceConflictError ? e.message : e instanceof Error ? e.message : "Opslaan mislukt");
|
|
120
137
|
}
|
|
121
138
|
finally {
|
|
122
|
-
|
|
139
|
+
saveRef.current = false;
|
|
123
140
|
setSaving(false);
|
|
124
141
|
}
|
|
125
142
|
};
|
|
126
|
-
const
|
|
127
|
-
|
|
143
|
+
const saveNote = async () => {
|
|
144
|
+
if (noteSaving || !invoice)
|
|
145
|
+
return;
|
|
146
|
+
setNoteSaving(true);
|
|
128
147
|
try {
|
|
129
|
-
await
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
148
|
+
const at = await setInvoiceNote(id, noteText);
|
|
149
|
+
const clean = noteText.trim();
|
|
150
|
+
setInvoice((prev) => (prev ? { ...prev, note: clean || null } : prev));
|
|
151
|
+
setNoteText(clean);
|
|
152
|
+
setNoteSavedAt(at ?? new Date().toISOString());
|
|
153
|
+
toast.success("Notitie opgeslagen");
|
|
133
154
|
}
|
|
134
155
|
catch (e) {
|
|
135
|
-
toast.error(e instanceof Error ? e.message : "
|
|
156
|
+
toast.error(e instanceof Error ? e.message : "Notitie opslaan mislukt");
|
|
136
157
|
}
|
|
137
158
|
finally {
|
|
138
|
-
|
|
159
|
+
setNoteSaving(false);
|
|
139
160
|
}
|
|
140
161
|
};
|
|
141
162
|
const pdfFilename = `Factuur-${invoice?.invoice_number ?? "concept"}.pdf`;
|
|
@@ -167,9 +188,8 @@ export function FactuurDetailPage() {
|
|
|
167
188
|
return;
|
|
168
189
|
setPreparingPdf(true);
|
|
169
190
|
try {
|
|
170
|
-
const pdf = await elementToPdfBase64(sheetRef.current);
|
|
191
|
+
const pdf = await elementToPdfBase64(sheetRef.current, { header: `Factuur ${invoice?.invoice_number ?? ""}`.trim() });
|
|
171
192
|
setPreparedPdf(pdf);
|
|
172
|
-
downloadPdfBase64(pdf, pdfFilename);
|
|
173
193
|
}
|
|
174
194
|
catch (error) {
|
|
175
195
|
toast.error(error instanceof Error ? error.message : "De factuur-PDF kon niet worden aangemaakt");
|
|
@@ -274,5 +294,10 @@ export function FactuurDetailPage() {
|
|
|
274
294
|
}
|
|
275
295
|
const isConcept = invoice.status === "concept";
|
|
276
296
|
const wasSent = Boolean(invoice.sent_at);
|
|
277
|
-
return (_jsxs("main", { className: "space-y-6 p-6", children: [_jsxs("div", { className: "sticky top-16 z-20 -mx-6 flex flex-wrap items-center gap-3 border-b border-border/60 bg-background px-6 py-3", children: [_jsx(Button, { asChild: true, variant: "ghost", size: "sm", children: _jsxs(Link, { to: "/facturen-installatietechniek", className: "gap-1", children: [_jsx(ArrowLeft, { className: "h-4 w-4" }), "Overzicht"] }) }), _jsx("h1", { className: "text-xl font-semibold", children: invoice.invoice_number ?? "Concept" }), _jsxs(Badge, { variant: "outline", children: [SOURCE_LABELS[invoice.source_type] ?? invoice.source_type, invoice.source_number ? ` ${invoice.source_number}` : ""] }), invoice.sent_at ? (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["Verzonden ", new Date(invoice.sent_at).toLocaleString("nl-NL"), " aan ", invoice.sent_to] })) : null, _jsxs("div", { className: "ml-auto flex flex-wrap items-center gap-2", children: [_jsxs("div", { className: "flex items-center gap-1 rounded-full border border-border bg-card p-1", children: [_jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 rounded-full", "aria-label": "Vorige factuur op de lijst", title: "Vorige factuur op de lijst", disabled: editing || loading || siblingsLoading || !prevInvoice, onClick: () => goToSibling(prevInvoice?.id), children: _jsx(ChevronLeft, { className: "h-5 w-5" }) }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 rounded-full", "aria-label": "Volgende factuur op de lijst", title: "Volgende factuur op de lijst", disabled: editing || loading || siblingsLoading || !nextInvoice, onClick: () => goToSibling(nextInvoice?.id), children: _jsx(ChevronRight, { className: "h-5 w-5" }) })] }), editing ? (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "outline", onClick: () => { setEditing(false); void load(); }, disabled: saving, children: [_jsx(X, { className: "mr-2 h-4 w-4" }), "Annuleren"] }), _jsxs(Button, { onClick: save, disabled: saving, children: [_jsx(Save, { className: "mr-2 h-4 w-4" }), saving ? "Opslaan…" : "Opslaan"] })] })) : (_jsxs(_Fragment, { children: [!wasSent ? (_jsxs(Button, { variant: "outline", onClick: () => (isConcept ? setEditing(true) : toast.error("Alleen conceptfacturen kunnen worden bewerkt. Zet de status terug op Concept.")), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Bewerken"] })) : null, _jsxs(Button, { onClick: () => void openSend(), children: [_jsx(Mail, { className: "mr-2 h-4 w-4" }), wasSent ? "Opnieuw verzenden" : "Verzenden"] })] })), _jsx("
|
|
297
|
+
return (_jsxs("main", { className: "space-y-6 p-6", children: [_jsxs("div", { className: "sticky top-16 z-20 -mx-6 flex flex-wrap items-center gap-3 border-b border-border/60 bg-background px-6 py-3", children: [_jsx(Button, { asChild: true, variant: "ghost", size: "sm", children: _jsxs(Link, { to: "/facturen-installatietechniek", className: "gap-1", children: [_jsx(ArrowLeft, { className: "h-4 w-4" }), "Overzicht"] }) }), _jsx("h1", { className: "text-xl font-semibold", children: invoice.invoice_number ?? "Concept" }), _jsxs(Badge, { variant: "outline", children: [SOURCE_LABELS[invoice.source_type] ?? invoice.source_type, invoice.source_number ? ` ${invoice.source_number}` : ""] }), invoice.sent_at ? (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["Verzonden ", new Date(invoice.sent_at).toLocaleString("nl-NL"), " aan ", invoice.sent_to] })) : null, _jsxs("div", { className: "ml-auto flex flex-wrap items-center gap-2", children: [_jsxs("div", { className: "flex items-center gap-1 rounded-full border border-border bg-card p-1", children: [_jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 rounded-full", "aria-label": "Vorige factuur op de lijst", title: "Vorige factuur op de lijst", disabled: editing || loading || siblingsLoading || !prevInvoice, onClick: () => goToSibling(prevInvoice?.id), children: _jsx(ChevronLeft, { className: "h-5 w-5" }) }), _jsx(Button, { variant: "ghost", size: "icon", className: "h-8 w-8 rounded-full", "aria-label": "Volgende factuur op de lijst", title: "Volgende factuur op de lijst", disabled: editing || loading || siblingsLoading || !nextInvoice, onClick: () => goToSibling(nextInvoice?.id), children: _jsx(ChevronRight, { className: "h-5 w-5" }) })] }), editing ? (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "outline", onClick: () => { setEditing(false); void load(); }, disabled: saving, children: [_jsx(X, { className: "mr-2 h-4 w-4" }), "Annuleren"] }), _jsxs(Button, { onClick: save, disabled: saving || Boolean(lineError), title: lineError ?? undefined, children: [_jsx(Save, { className: "mr-2 h-4 w-4" }), saving ? "Opslaan…" : "Opslaan"] })] })) : (_jsxs(_Fragment, { children: [!wasSent ? (_jsxs(Button, { variant: "outline", onClick: () => (isConcept ? setEditing(true) : toast.error("Alleen conceptfacturen kunnen worden bewerkt. Zet de status terug op Concept.")), children: [_jsx(Pencil, { className: "mr-2 h-4 w-4" }), "Bewerken"] })) : null, _jsxs(Button, { onClick: () => void openSend(), children: [_jsx(Mail, { className: "mr-2 h-4 w-4" }), wasSent ? "Opnieuw verzenden" : "Verzenden"] })] })), _jsx(Badge, { variant: "secondary", className: "px-3 py-1.5 text-sm", title: "De status wordt automatisch bepaald", children: STATUS_LABELS[invoice.status] ?? invoice.status })] })] }), _jsxs("div", { className: "flex flex-col gap-6 xl:flex-row xl:items-start", children: [_jsxs("aside", { className: "w-full shrink-0 space-y-2 rounded-lg border border-border bg-card p-4 xl:sticky xl:top-36 xl:w-72", children: [_jsx(Label, { htmlFor: "inv-note", className: "text-sm font-semibold", children: "Notitie" }), _jsx("p", { className: "text-xs text-muted-foreground", children: "Alleen intern. Komt niet op de factuur of in de e-mail." }), _jsx(Textarea, { id: "inv-note", rows: 6, value: noteText, onChange: (e) => setNoteText(e.target.value), placeholder: "Typ hier een notitie\u2026" }), _jsxs(Button, { size: "sm", className: "w-full", onClick: () => void saveNote(), disabled: noteSaving || noteText === (invoice.note ?? ""), children: [_jsx(Save, { className: "mr-2 h-4 w-4" }), noteSaving ? "Opslaan…" : "Notitie opslaan"] }), noteSavedAt ? (_jsxs("p", { className: "text-xs text-muted-foreground", children: ["Laatst opgeslagen ", new Date(noteSavedAt).toLocaleString("nl-NL")] })) : null] }), _jsx("div", { className: "min-w-0 flex-1", children: _jsx(InvoiceA4Preview, { ref: sheetRef, branding: invoice.branding_snapshot ?? emptyInvoiceBranding(), details: details, lines: lines, invoiceNumber: invoice.invoice_number, logoUrl: logoUrl, printable: !editing, editable: editing, defaultVat: String(Number(invoice.vat_rate) || settings.defaultVatRate), onDetailsChange: setDetails, onLinesChange: setLines, reverseCharge: reverseCharge, onReverseChargeChange: setReverseCharge }) })] }), _jsx(Dialog, { open: sendOpen, onOpenChange: changeSendOpen, children: _jsxs(DialogContent, { className: "flex max-h-[92vh] max-w-[calc(100vw-2rem)] flex-col sm:max-w-lg", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Factuur verzenden" }) }), _jsxs("div", { className: "min-h-0 flex-1 space-y-3 overflow-y-auto pr-1", children: [_jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "mail-to", children: "E-mailadres klant" }), _jsx(Input, { id: "mail-to", type: "email", value: mail.to, onChange: (e) => setMail({ ...mail, to: e.target.value }), onBlur: () => setMailToTouched(true), placeholder: "naam@bedrijf.nl", "aria-invalid": mailToTouched && !isValidEmail(mail.to) }), mailToTouched && !isValidEmail(mail.to) ? (_jsx("p", { className: "text-xs text-destructive", children: "Vul een geldig e-mailadres in, bijvoorbeeld naam@bedrijf.nl" })) : null] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "mail-subject", children: "Onderwerp" }), _jsx(Input, { id: "mail-subject", value: mail.subject, onChange: (e) => setMail({ ...mail, subject: e.target.value }) })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { htmlFor: "mail-msg", children: "Bericht" }), _jsx(Textarea, { id: "mail-msg", rows: 6, value: mail.message, onChange: (e) => setMail({ ...mail, message: e.target.value }) })] }), _jsxs("label", { className: "flex cursor-pointer items-start gap-3 rounded-md border border-border p-3", children: [_jsx("input", { type: "checkbox", checked: attachPdf, onChange: (event) => setAttachPdf(event.target.checked), className: "mt-1 h-4 w-4 accent-primary" }), _jsxs("span", { className: "min-w-0 flex-1", children: [_jsxs("span", { className: "flex items-center gap-2 text-sm font-medium", children: [preparedPdf ? _jsx(FileCheck2, { className: "h-4 w-4 text-primary" }) : _jsx(Download, { className: "h-4 w-4" }), "Factuur als PDF meesturen"] }), _jsx("span", { className: "mt-1 block truncate text-xs text-muted-foreground", children: preparingPdf ? "PDF wordt aangemaakt…" : preparedPdf ? `${pdfFilename} is klaar als bijlage.` : "PDF kon niet worden voorbereid." })] })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: !preparedPdf || preparingPdf, onClick: () => { try {
|
|
298
|
+
downloadPdfBase64(preparedPdf, pdfFilename);
|
|
299
|
+
}
|
|
300
|
+
catch (e) {
|
|
301
|
+
toast.error(e instanceof Error ? e.message : "Downloaden mislukt");
|
|
302
|
+
} }, children: [_jsx(Download, { className: "mr-2 h-4 w-4" }), "PDF downloaden"] }), !attachPdf ? _jsx("p", { className: "text-xs text-destructive", children: "Vink de PDF aan om deze e-mail te kunnen versturen." }) : null, _jsxs("p", { className: "text-xs text-muted-foreground", children: [!senderChecked ? _jsx(_Fragment, { children: "Afzender controleren\u2026 " }) : sender ? _jsxs(_Fragment, { children: ["Verzonden vanaf ", _jsx("span", { className: "font-medium text-foreground", children: sender.account_email }), ". "] }) : _jsx("span", { className: "text-destructive", children: "Geen afzender ingesteld (Instellingen \u2192 Factuur e-mail). " }), "De factuur wordt als PDF-bijlage aangeboden aan de e-mailmodule."] }), _jsxs(Button, { className: "w-full", onClick: send, disabled: saving || preparingPdf || (senderChecked && !sender) || !attachPdf || !preparedPdf || !isValidEmail(mail.to), children: [_jsx(Mail, { className: "mr-2 h-4 w-4" }), saving ? "Bezig…" : preparingPdf ? "PDF voorbereiden…" : "Verzenden met PDF"] })] })] }) }), _jsx(Dialog, { open: confirmOpen, onOpenChange: setConfirmOpen, children: _jsxs(DialogContent, { className: "max-w-md", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Ander e-mailadres dan bekend" }) }), _jsxs("p", { className: "text-sm text-muted-foreground", children: [knownEmail ? (_jsxs(_Fragment, { children: ["Dit adres wijkt af van het bekende adres van de klant (", _jsx("span", { className: "font-medium text-foreground", children: knownEmail }), ")."] })) : (_jsx(_Fragment, { children: "Er is geen e-mailadres van deze klant bekend." })), " ", "Weet je zeker dat je naar ", _jsx("span", { className: "font-medium text-foreground", children: mail.to.trim() }), " wilt verzenden?"] }), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "outline", onClick: () => setConfirmOpen(false), children: "Annuleren" }), _jsx(Button, { onClick: () => void doSend(), disabled: saving, children: "Toch verzenden" })] })] }) })] }));
|
|
278
303
|
}
|
|
@@ -13,6 +13,9 @@ type Props = {
|
|
|
13
13
|
onLinesChange?: (l: EditLine[]) => void;
|
|
14
14
|
/** Toon klantkeuzelijst bij Klantnaam (alleen nieuwe handmatige factuur). */
|
|
15
15
|
onCustomerPick?: (c: CustomerOption) => void;
|
|
16
|
+
/** Btw verlegd naar afnemer: alle regels 0%, totaal zonder btw. */
|
|
17
|
+
reverseCharge?: boolean;
|
|
18
|
+
onReverseChargeChange?: (v: boolean) => void;
|
|
16
19
|
};
|
|
17
20
|
export declare const InvoiceA4Preview: import("react").ForwardRefExoticComponent<Props & import("react").RefAttributes<HTMLElement>>;
|
|
18
21
|
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"InvoiceA4Preview.d.ts","sourceRoot":"","sources":["../../src/components/InvoiceA4Preview.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"InvoiceA4Preview.d.ts","sourceRoot":"","sources":["../../src/components/InvoiceA4Preview.tsx"],"names":[],"mappings":"AAOA,OAAO,EAWL,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,cAAc,EAGpB,MAAM,iBAAiB,CAAC;AAIzB,KAAK,KAAK,GAAG;IACX,QAAQ,EAAE,eAAe,CAAC;IAC1B,OAAO,EAAE,cAAc,CAAC;IACxB,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,CAAC,CAAC,EAAE,cAAc,KAAK,IAAI,CAAC;IAC9C,aAAa,CAAC,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,KAAK,IAAI,CAAC;IACxC,6EAA6E;IAC7E,cAAc,CAAC,EAAE,CAAC,CAAC,EAAE,cAAc,KAAK,IAAI,CAAC;IAC7C,mEAAmE;IACnE,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qBAAqB,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CAC9C,CAAC;AAoDF,eAAO,MAAM,gBAAgB,+FAuN3B,CAAC"}
|