@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.
Files changed (35) hide show
  1. package/README.md +27 -0
  2. package/dist-lib/components/FacturenForInstallatieTechniek.d.ts.map +1 -1
  3. package/dist-lib/components/FacturenForInstallatieTechniek.js +101 -25
  4. package/dist-lib/components/FactuurDetailPage.d.ts.map +1 -1
  5. package/dist-lib/components/FactuurDetailPage.js +49 -24
  6. package/dist-lib/components/InvoiceA4Preview.d.ts +3 -0
  7. package/dist-lib/components/InvoiceA4Preview.d.ts.map +1 -1
  8. package/dist-lib/components/InvoiceA4Preview.js +43 -8
  9. package/dist-lib/components/settings/InvoiceModuleSettingsCard.d.ts +2 -0
  10. package/dist-lib/components/settings/InvoiceModuleSettingsCard.d.ts.map +1 -0
  11. package/dist-lib/components/settings/InvoiceModuleSettingsCard.js +53 -0
  12. package/dist-lib/components/settings/ModuleHealthSettingsCard.d.ts.map +1 -1
  13. package/dist-lib/components/settings/ModuleHealthSettingsCard.js +2 -0
  14. package/dist-lib/index.d.ts +2 -0
  15. package/dist-lib/index.d.ts.map +1 -1
  16. package/dist-lib/index.js +5 -2
  17. package/dist-lib/integrations/supabase/types.d.ts +10550 -213
  18. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  19. package/dist-lib/integrations/supabase/types.js +1 -0
  20. package/dist-lib/lib/config.d.ts +0 -4
  21. package/dist-lib/lib/config.d.ts.map +1 -1
  22. package/dist-lib/lib/config.js +2 -4
  23. package/dist-lib/lib/dependencies.d.ts +2 -0
  24. package/dist-lib/lib/dependencies.d.ts.map +1 -1
  25. package/dist-lib/lib/dependencies.js +19 -0
  26. package/dist-lib/lib/facturen.d.ts +74 -5
  27. package/dist-lib/lib/facturen.d.ts.map +1 -1
  28. package/dist-lib/lib/facturen.js +195 -43
  29. package/dist-lib/lib/rekenregels.d.ts +49 -0
  30. package/dist-lib/lib/rekenregels.d.ts.map +1 -0
  31. package/dist-lib/lib/rekenregels.js +135 -0
  32. package/dist-lib/lib/settings.d.ts +32 -0
  33. package/dist-lib/lib/settings.d.ts.map +1 -0
  34. package/dist-lib/lib/settings.js +117 -0
  35. 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":"AAiJA,wBAAgB,8BAA8B,gCAge7C"}
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 { DEFAULT_VAT_RATE } from "../lib/config";
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) => (_jsxs(TableRow, { className: "cursor-pointer", onClick: () => onOpen(invoice.id), children: [_jsx(TableCell, { className: "font-medium", children: _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]
26
- ? SOURCE_LABELS[invoice.source_type] + (invoice.source_number ? ` ${invoice.source_number}` : "")
27
- : "—" }) }), _jsx(TableCell, { children: new Date(invoice.invoice_date).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))) })] }));
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 [vatRate, setVatRate] = useState(String(DEFAULT_VAT_RATE));
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
- const now = new Date();
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.getFullYear() === now.getFullYear();
223
+ return d.slice(0, 4) === today.slice(0, 4);
164
224
  if (paidPeriod === "maand")
165
- return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
166
- // huidige kalenderweek (maandag t/m zondag)
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(new Date(i.invoice_date)))
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
- toast.error("Factuur aanmaken mislukt");
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: parseFloat(vatRate.replace(",", ".")) || DEFAULT_VAT_RATE,
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
- toast.error("Factuur aanmaken mislukt");
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)))] })] }), _jsx("div", { className: "ml-auto", children: _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" }) }), _jsx(CardContent, { children: _jsx(InvoiceTable, { invoices: conceptInvoices, emptyText: "Geen conceptfacturen.", onOpen: openInvoice }) })] }), _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: 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) => {
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":"AAoDA,wBAAgB,iBAAiB,gCA6bhC"}
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 { DEFAULT_VAT_RATE } from "../lib/config";
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, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea, toast, } from "@flowselections/core";
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, updateInvoiceStatus, } from "../lib/facturen";
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 ?? DEFAULT_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 changeStatus = async (status) => {
109
- if (statusRef.current)
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
- statusRef.current = true;
125
+ }
126
+ saveRef.current = true;
112
127
  setSaving(true);
113
128
  try {
114
- await updateInvoiceStatus(id, status);
115
- toast.success("Status bijgewerkt");
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
- toast.error("Status bijwerken mislukt");
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
- statusRef.current = false;
139
+ saveRef.current = false;
123
140
  setSaving(false);
124
141
  }
125
142
  };
126
- const save = async () => {
127
- setSaving(true);
143
+ const saveNote = async () => {
144
+ if (noteSaving || !invoice)
145
+ return;
146
+ setNoteSaving(true);
128
147
  try {
129
- await updateInvoiceFull(id, details, lines, invoice?.note ?? null);
130
- toast.success("Factuur opgeslagen");
131
- setEditing(false);
132
- await load();
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 : "Opslaan mislukt");
156
+ toast.error(e instanceof Error ? e.message : "Notitie opslaan mislukt");
136
157
  }
137
158
  finally {
138
- setSaving(false);
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("div", { className: "w-44", children: _jsxs(Select, { value: invoice.status, onValueChange: (v) => changeStatus(v), disabled: saving || editing, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: Object.entries(STATUS_LABELS).map(([value, label]) => (_jsx(SelectItem, { value: value, children: label }, value))) })] }) })] })] }), _jsx("div", { children: _jsx("div", { className: "min-w-0", 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) || DEFAULT_VAT_RATE), onDetailsChange: setDetails, onLinesChange: setLines }) }) }), _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 en gedownload…" : preparedPdf ? `${pdfFilename} is gedownload en klaar als bijlage.` : "PDF kon niet worden voorbereid." })] })] }), !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" })] })] }) })] }));
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":"AAMA,OAAO,EAML,KAAK,cAAc,EACnB,KAAK,QAAQ,EACb,KAAK,eAAe,EACpB,KAAK,cAAc,EAEpB,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;CAC9C,CAAC;AAqDF,eAAO,MAAM,gBAAgB,+FA4K3B,CAAC"}
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"}