@flowselections/facturen-installatie-techniek 1.0.2 → 1.3.4

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 (53) hide show
  1. package/dist-lib/components/ArticlePhoto.d.ts +8 -0
  2. package/dist-lib/components/ArticlePhoto.d.ts.map +1 -0
  3. package/dist-lib/components/ArticlePhoto.js +7 -0
  4. package/dist-lib/components/FacturenForInstallatieTechniek.d.ts.map +1 -1
  5. package/dist-lib/components/FacturenForInstallatieTechniek.js +143 -50
  6. package/dist-lib/components/FactuurDetailPage.d.ts.map +1 -1
  7. package/dist-lib/components/FactuurDetailPage.js +224 -13
  8. package/dist-lib/components/InvoiceA4Preview.d.ts +19 -0
  9. package/dist-lib/components/InvoiceA4Preview.d.ts.map +1 -0
  10. package/dist-lib/components/InvoiceA4Preview.js +118 -0
  11. package/dist-lib/components/InvoiceDetailsFields.d.ts +7 -0
  12. package/dist-lib/components/InvoiceDetailsFields.d.ts.map +1 -0
  13. package/dist-lib/components/InvoiceDetailsFields.js +21 -0
  14. package/dist-lib/components/WerkbonPreviewPanel.d.ts +4 -0
  15. package/dist-lib/components/WerkbonPreviewPanel.d.ts.map +1 -0
  16. package/dist-lib/components/WerkbonPreviewPanel.js +40 -0
  17. package/dist-lib/components/settings/InstellingenPage.d.ts +5 -0
  18. package/dist-lib/components/settings/InstellingenPage.d.ts.map +1 -0
  19. package/dist-lib/components/settings/InstellingenPage.js +119 -0
  20. package/dist-lib/components/settings/InvoiceBrandingSettingsCard.d.ts +2 -0
  21. package/dist-lib/components/settings/InvoiceBrandingSettingsCard.d.ts.map +1 -0
  22. package/dist-lib/components/settings/InvoiceBrandingSettingsCard.js +64 -0
  23. package/dist-lib/components/settings/InvoiceEmailSettingsCard.d.ts +2 -0
  24. package/dist-lib/components/settings/InvoiceEmailSettingsCard.d.ts.map +1 -0
  25. package/dist-lib/components/settings/InvoiceEmailSettingsCard.js +41 -0
  26. package/dist-lib/index.d.ts +8 -0
  27. package/dist-lib/index.d.ts.map +1 -1
  28. package/dist-lib/index.js +14 -2
  29. package/dist-lib/integrations/supabase/auth-middleware.d.ts +11 -22550
  30. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  31. package/dist-lib/integrations/supabase/auth-middleware.js +3 -1
  32. package/dist-lib/integrations/supabase/client.d.ts +2 -22547
  33. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  34. package/dist-lib/integrations/supabase/client.js +4 -1
  35. package/dist-lib/integrations/supabase/client.server.d.ts +2 -22547
  36. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  37. package/dist-lib/integrations/supabase/client.server.js +1 -0
  38. package/dist-lib/integrations/supabase/previewAuthStorage.d.ts +6 -0
  39. package/dist-lib/integrations/supabase/previewAuthStorage.d.ts.map +1 -0
  40. package/dist-lib/integrations/supabase/previewAuthStorage.js +99 -0
  41. package/dist-lib/integrations/supabase/types.d.ts +24631 -11349
  42. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  43. package/dist-lib/integrations/supabase/types.js +2 -0
  44. package/dist-lib/lib/articles.functions.d.ts +19 -0
  45. package/dist-lib/lib/articles.functions.d.ts.map +1 -0
  46. package/dist-lib/lib/articles.functions.js +91 -0
  47. package/dist-lib/lib/config.d.ts +16 -0
  48. package/dist-lib/lib/config.d.ts.map +1 -0
  49. package/dist-lib/lib/config.js +17 -0
  50. package/dist-lib/lib/facturen.d.ts +196 -0
  51. package/dist-lib/lib/facturen.d.ts.map +1 -1
  52. package/dist-lib/lib/facturen.js +441 -16
  53. package/package.json +57 -5
@@ -0,0 +1,8 @@
1
+ type ArticlePhotoProps = {
2
+ src?: string | null;
3
+ alt: string;
4
+ className?: string;
5
+ };
6
+ export declare function ArticlePhoto({ src, alt, className }: ArticlePhotoProps): import("react").JSX.Element;
7
+ export {};
8
+ //# sourceMappingURL=ArticlePhoto.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ArticlePhoto.d.ts","sourceRoot":"","sources":["../../src/components/ArticlePhoto.tsx"],"names":[],"mappings":"AAEA,KAAK,iBAAiB,GAAG;IACvB,GAAG,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,wBAAgB,YAAY,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,SAAuB,EAAE,EAAE,iBAAiB,+BAcpF"}
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useState } from "react";
3
+ export function ArticlePhoto({ src, alt, className = "h-14 w-14" }) {
4
+ const [failed, setFailed] = useState(false);
5
+ useEffect(() => setFailed(false), [src]);
6
+ return (_jsx("div", { className: `shrink-0 overflow-hidden rounded-sm bg-muted ${className}`, children: src && !failed ? (_jsx("img", { src: src, alt: alt, className: "h-full w-full object-cover", onError: () => setFailed(true) })) : (_jsx("div", { className: "h-full w-full", role: "img", "aria-label": `Geen foto beschikbaar voor ${alt}` })) }));
7
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"FacturenForInstallatieTechniek.d.ts","sourceRoot":"","sources":["../../src/components/FacturenForInstallatieTechniek.tsx"],"names":[],"mappings":"AA2DA,wBAAgB,8BAA8B,gCAoZ7C"}
1
+ {"version":3,"file":"FacturenForInstallatieTechniek.d.ts","sourceRoot":"","sources":["../../src/components/FacturenForInstallatieTechniek.tsx"],"names":[],"mappings":"AAgJA,wBAAgB,8BAA8B,gCAyd7C"}
@@ -1,10 +1,13 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { WerkbonPreviewPanel } from "../components/WerkbonPreviewPanel";
2
3
  import { useCallback, useEffect, useMemo, useState } from "react";
3
- import { Link } from "@tanstack/react-router";
4
+ import { DEFAULT_VAT_RATE } from "../lib/config";
5
+ import { supabase } from "@flowselections/core";
6
+ import { Link, useNavigate } from "@tanstack/react-router";
4
7
  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";
5
- import { Plus, Trash2 } from "lucide-react";
6
- import { createInvoiceFromSource, euroToCents, fetchInvoices, fetchSourceLines, formatCents, listSources, SOURCE_LABELS, STATUS_LABELS, } from "../lib/facturen";
7
- const emptyLine = () => ({ description: "", quantity: "1", unit: "stuk", price: "" });
8
+ import { Plus } from "lucide-react";
9
+ import { createInvoiceFromSource, emptyInvoiceDetails, fetchInvoicesDetailed, fetchInvoiceKpis, fetchSourceLines, formatCents, listSources, SOURCE_LABELS, STATUS_LABELS, emptyInvoiceBranding, fetchInvoiceBranding, getInvoiceLogoUrl, editLineToBillable, emptyEditLine, peekNextInvoiceNumber, } from "../lib/facturen";
10
+ import { InvoiceA4Preview } from "./InvoiceA4Preview";
8
11
  function statusVariant(status) {
9
12
  if (status === "betaald")
10
13
  return "default";
@@ -14,11 +17,26 @@ function statusVariant(status) {
14
17
  return "outline";
15
18
  return "secondary";
16
19
  }
20
+ function InvoiceTable({ invoices, emptyText, onOpen, }) {
21
+ if (invoices.length === 0) {
22
+ return _jsx("p", { className: "text-sm text-muted-foreground", children: emptyText });
23
+ }
24
+ 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]
25
+ ? SOURCE_LABELS[invoice.source_type] + (invoice.source_number ? ` ${invoice.source_number}` : "")
26
+ : "—" }) }), _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))) })] }));
27
+ }
17
28
  export function FacturenForInstallatieTechniek() {
29
+ const navigate = useNavigate();
18
30
  const [invoices, setInvoices] = useState([]);
19
31
  const [loading, setLoading] = useState(true);
32
+ const [sessionExpired, setSessionExpired] = useState(false);
33
+ const [loadError, setLoadError] = useState(false);
34
+ const [hasMore, setHasMore] = useState(false);
35
+ const [loadingMore, setLoadingMore] = useState(false);
36
+ const [kpiData, setKpiData] = useState(null);
20
37
  const [statusFilter, setStatusFilter] = useState("alle");
21
38
  const [sourceFilter, setSourceFilter] = useState("alle");
39
+ const [paidPeriod, setPaidPeriod] = useState("maand");
22
40
  const [open, setOpen] = useState(false);
23
41
  const [saving, setSaving] = useState(false);
24
42
  // bron-tab
@@ -27,22 +45,83 @@ export function FacturenForInstallatieTechniek() {
27
45
  const [sourcesLoading, setSourcesLoading] = useState(false);
28
46
  const [selectedSource, setSelectedSource] = useState("");
29
47
  // handmatig-tab
30
- const [customerName, setCustomerName] = useState("");
31
- const [invoiceDate, setInvoiceDate] = useState(() => new Date().toISOString().slice(0, 10));
32
- const [vatRate, setVatRate] = useState("21");
33
- const [lines, setLines] = useState([emptyLine()]);
48
+ const [pickedCustomerId, setPickedCustomerId] = useState(null);
49
+ const [tab, setTab] = useState("handmatig");
50
+ const [details, setDetails] = useState(emptyInvoiceDetails);
51
+ const [vatRate, setVatRate] = useState(String(DEFAULT_VAT_RATE));
52
+ const [lines, setLines] = useState([emptyEditLine()]);
53
+ const [nextNumber, setNextNumber] = useState(null);
54
+ const [linkedWorkOrder, setLinkedWorkOrder] = useState(null);
55
+ const [branding, setBranding] = useState(emptyInvoiceBranding);
56
+ const [logoUrl, setLogoUrl] = useState(null);
57
+ const resetForm = () => {
58
+ setPickedCustomerId(null);
59
+ setDetails(emptyInvoiceDetails(branding.payment_term_days));
60
+ setLines([emptyEditLine(vatRate)]);
61
+ setLinkedWorkOrder(null);
62
+ };
63
+ const openNew = () => {
64
+ resetForm();
65
+ setTab("handmatig");
66
+ setOpen(true);
67
+ };
68
+ useEffect(() => {
69
+ if (!open)
70
+ return;
71
+ fetchInvoiceBranding().then(async (row) => {
72
+ setBranding(row);
73
+ setLogoUrl(await getInvoiceLogoUrl(row.logo_path));
74
+ setDetails((current) => ({
75
+ ...current,
76
+ payment_term_days: current.payment_term_days === "30" ? String(row.payment_term_days) : current.payment_term_days,
77
+ }));
78
+ }).catch(() => toast.error("Factuurhuisstijl kon niet worden geladen"));
79
+ peekNextInvoiceNumber().then(setNextNumber);
80
+ }, [open]);
34
81
  const load = useCallback(async () => {
35
82
  setLoading(true);
36
83
  try {
37
- setInvoices(await fetchInvoices());
84
+ const { data: userData } = await supabase.auth.getUser();
85
+ if (!userData?.user) {
86
+ setSessionExpired(true);
87
+ setLoadError(false);
88
+ setInvoices([]);
89
+ return;
90
+ }
91
+ setSessionExpired(false);
92
+ const result = await fetchInvoicesDetailed({ status: statusFilter, source: sourceFilter });
93
+ setLoadError(result.failed);
94
+ setInvoices(result.failed ? [] : result.invoices);
95
+ setHasMore(result.hasMore);
96
+ fetchInvoiceKpis().then(setKpiData).catch(() => setKpiData(null));
38
97
  }
39
98
  catch {
40
- toast.error("Fout bij het laden van facturen");
99
+ setLoadError(true);
100
+ setInvoices([]);
41
101
  }
42
102
  finally {
43
103
  setLoading(false);
44
104
  }
45
- }, []);
105
+ }, [statusFilter, sourceFilter]);
106
+ const loadMore = useCallback(async () => {
107
+ setLoadingMore(true);
108
+ try {
109
+ const offset = invoices.filter((i) => i.status !== "concept").length;
110
+ const result = await fetchInvoicesDetailed({ offset, status: statusFilter, source: sourceFilter });
111
+ if (result.failed) {
112
+ toast.error("Meer facturen laden is mislukt");
113
+ return;
114
+ }
115
+ setInvoices((prev) => {
116
+ const seen = new Set(prev.map((i) => i.id));
117
+ return [...prev, ...result.invoices.filter((i) => !seen.has(i.id))];
118
+ });
119
+ setHasMore(result.hasMore);
120
+ }
121
+ finally {
122
+ setLoadingMore(false);
123
+ }
124
+ }, [invoices, statusFilter, sourceFilter]);
46
125
  useEffect(() => {
47
126
  void load();
48
127
  }, [load]);
@@ -68,20 +147,34 @@ export function FacturenForInstallatieTechniek() {
68
147
  }, [open, sourceType]);
69
148
  const filtered = useMemo(() => invoices.filter((i) => (statusFilter === "alle" || i.status === statusFilter) &&
70
149
  (sourceFilter === "alle" || i.source_type === sourceFilter)), [invoices, statusFilter, sourceFilter]);
150
+ const conceptInvoices = useMemo(() => filtered.filter((i) => i.status === "concept"), [filtered]);
151
+ const sendOrPaidInvoices = useMemo(() => filtered.filter((i) => i.status !== "concept"), [filtered]);
152
+ const openInvoice = useCallback((id) => navigate({
153
+ to: "/facturen-installatietechniek/detail/$id",
154
+ params: { id },
155
+ search: { send: false },
156
+ }), [navigate]);
71
157
  const kpi = useMemo(() => {
72
158
  const now = new Date();
73
- const openCents = invoices
74
- .filter((i) => i.status === "verzonden" || i.status === "vervallen")
75
- .reduce((sum, i) => sum + (i.total_cents || 0), 0);
76
- const overdue = invoices.filter((i) => i.status === "vervallen").length;
77
- const monthCents = invoices
78
- .filter((i) => {
79
- const d = new Date(i.invoice_date);
80
- return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
81
- })
82
- .reduce((sum, i) => sum + (i.total_cents || 0), 0);
83
- return { openCents, overdue, monthCents };
84
- }, [invoices]);
159
+ const inPeriod = (d) => {
160
+ if (paidPeriod === "jaar")
161
+ return d.getFullYear() === now.getFullYear();
162
+ if (paidPeriod === "maand")
163
+ return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth();
164
+ // huidige kalenderweek (maandag t/m zondag)
165
+ const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
166
+ start.setDate(start.getDate() - ((start.getDay() + 6) % 7));
167
+ const end = new Date(start);
168
+ end.setDate(end.getDate() + 7);
169
+ return d >= start && d < end;
170
+ };
171
+ const paidRows = Array.isArray(kpiData?.paidThisYear) ? kpiData.paidThisYear : [];
172
+ const paidMonthCents = paidRows
173
+ .filter((i) => inPeriod(new Date(i.invoice_date)))
174
+ .reduce((sum, i) => sum + (Number(i.total_cents) || 0), 0);
175
+ const openCount = kpiData?.openCount ?? 0;
176
+ return { paidMonthCents, openCount };
177
+ }, [kpiData, paidPeriod]);
85
178
  const createFromSource = async () => {
86
179
  const source = sources.find((s) => s.id === selectedSource);
87
180
  if (!source) {
@@ -115,40 +208,29 @@ export function FacturenForInstallatieTechniek() {
115
208
  }
116
209
  };
117
210
  const createManual = async () => {
118
- const payloadLines = lines
119
- .filter((l) => l.description.trim() !== "")
120
- .map((l, index) => {
121
- const quantity = parseFloat(l.quantity.replace(",", ".")) || 0;
122
- const unitPrice = euroToCents(l.price);
123
- return {
124
- description: l.description.trim(),
125
- kind: "other",
126
- quantity,
127
- unit: l.unit || "stuk",
128
- unit_price_cents: unitPrice,
129
- line_total_cents: Math.round(quantity * unitPrice),
130
- sort_order: index + 1,
131
- };
132
- });
133
- if (!customerName.trim() || payloadLines.length === 0) {
211
+ const payloadLines = lines.filter((l) => l.description.trim() !== "").map(editLineToBillable);
212
+ if (!details.customer_name.trim() || payloadLines.length === 0) {
134
213
  toast.error("Vul een klantnaam en minimaal één regel in");
135
214
  return;
136
215
  }
137
216
  setSaving(true);
138
217
  try {
139
218
  const result = await createInvoiceFromSource({
140
- sourceType: "manual",
141
- sourceId: null,
142
- customerName: customerName.trim(),
143
- vatRate: parseFloat(vatRate.replace(",", ".")) || 21,
144
- invoiceDate,
219
+ sourceType: linkedWorkOrder ? "work_order" : "manual",
220
+ sourceId: linkedWorkOrder?.id ?? null,
221
+ customerId: linkedWorkOrder?.customer_id ?? pickedCustomerId ?? null,
222
+ vatRate: parseFloat(vatRate.replace(",", ".")) || DEFAULT_VAT_RATE,
145
223
  lines: payloadLines,
224
+ details,
146
225
  });
147
- toast.success(`Factuur ${result.invoice_number} aangemaakt`);
148
- setCustomerName("");
149
- setLines([emptyLine()]);
226
+ toast.success(result.reused ? `Bestaand concept ${result.invoice_number}` : `Factuur ${result.invoice_number} aangemaakt`);
227
+ resetForm();
150
228
  setOpen(false);
151
- await load();
229
+ await navigate({
230
+ to: "/facturen-installatietechniek/detail/$id",
231
+ params: { id: result.id },
232
+ search: { send: true },
233
+ });
152
234
  }
153
235
  catch {
154
236
  toast.error("Factuur aanmaken mislukt");
@@ -157,5 +239,16 @@ export function FacturenForInstallatieTechniek() {
157
239
  setSaving(false);
158
240
  }
159
241
  };
160
- return (_jsxs("main", { className: "space-y-6 p-6", children: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_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: formatCents(kpi.openCents) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: "Vervallen" }) }), _jsx(CardContent, { className: "text-2xl font-semibold", children: kpi.overdue })] }), _jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: "Deze maand" }) }), _jsx(CardContent, { className: "text-2xl font-semibold", children: formatCents(kpi.monthCents) })] })] }), _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: () => setOpen(true), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Nieuwe factuur"] }) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Facturen" }) }), _jsx(CardContent, { children: loading ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "Laden\u2026" })) : filtered.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "Geen facturen gevonden." })) : (_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: filtered.map((invoice) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "font-medium", children: _jsx(Link, { to: "/facturen-installatietechniek/detail/$id", params: { id: invoice.id }, className: "underline-offset-2 hover:underline", children: invoice.invoice_number ?? "—" }) }), _jsx(TableCell, { children: invoice.customer_name ?? "—" }), _jsx(TableCell, { children: _jsxs(Badge, { variant: "outline", children: [SOURCE_LABELS[invoice.source_type] ?? invoice.source_type, invoice.source_number ? ` ${invoice.source_number}` : ""] }) }), _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))) })] })) })] }), _jsx(Dialog, { open: open, onOpenChange: setOpen, children: _jsxs(DialogContent, { className: "max-w-3xl", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Nieuwe factuur" }) }), _jsxs(Tabs, { defaultValue: "bron", 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: [_jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs("div", { className: "space-y-2 sm:col-span-1", children: [_jsx(Label, { htmlFor: "klant", children: "Klantnaam" }), _jsx(Input, { id: "klant", value: customerName, onChange: (e) => setCustomerName(e.target.value) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "datum", children: "Factuurdatum" }), _jsx(Input, { id: "datum", type: "date", value: invoiceDate, onChange: (e) => setInvoiceDate(e.target.value) })] }), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { htmlFor: "btw", children: "Btw %" }), _jsx(Input, { id: "btw", value: vatRate, onChange: (e) => setVatRate(e.target.value) })] })] }), _jsxs("div", { className: "space-y-3", children: [_jsx(Label, { children: "Regels" }), lines.map((line, index) => (_jsxs("div", { className: "grid grid-cols-12 items-end gap-2", children: [_jsx(Input, { className: "col-span-5", placeholder: "Omschrijving", value: line.description, onChange: (e) => setLines((prev) => prev.map((l, i) => (i === index ? { ...l, description: e.target.value } : l))) }), _jsx(Input, { className: "col-span-2", placeholder: "Aantal", value: line.quantity, onChange: (e) => setLines((prev) => prev.map((l, i) => (i === index ? { ...l, quantity: e.target.value } : l))) }), _jsx(Input, { className: "col-span-2", placeholder: "Eenheid", value: line.unit, onChange: (e) => setLines((prev) => prev.map((l, i) => (i === index ? { ...l, unit: e.target.value } : l))) }), _jsx(Input, { className: "col-span-2", placeholder: "Prijs", value: line.price, onChange: (e) => setLines((prev) => prev.map((l, i) => (i === index ? { ...l, price: e.target.value } : l))) }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "col-span-1", onClick: () => setLines((prev) => (prev.length === 1 ? [emptyLine()] : prev.filter((_, i) => i !== index))), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, index))), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setLines((prev) => [...prev, emptyLine()]), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Regel toevoegen"] })] }), _jsx(Button, { onClick: createManual, disabled: saving, className: "w-full", children: saving ? "Bezig…" : "Factuur aanmaken" })] })] })] }) })] }));
242
+ 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) => {
243
+ setPickedCustomerId(c.id);
244
+ setDetails((d) => ({
245
+ ...d,
246
+ customer_name: c.name ?? "",
247
+ customer_address: c.billing_street ?? "",
248
+ customer_postal_code: c.billing_postcode ?? "",
249
+ customer_city: c.billing_city ?? "",
250
+ debtor_number: c.customer_number ?? "",
251
+ customer_vat_number: c.vat_number ?? "",
252
+ }));
253
+ } }), _jsx(Button, { onClick: createManual, disabled: saving, className: "w-full", children: saving ? "Bezig…" : "Factuur aanmaken" })] })] })] })] })] }) })] }));
161
254
  }
@@ -1 +1 @@
1
- {"version":3,"file":"FactuurDetailPage.d.ts","sourceRoot":"","sources":["../../src/components/FactuurDetailPage.tsx"],"names":[],"mappings":"AAoCA,wBAAgB,iBAAiB,gCAsKhC"}
1
+ {"version":3,"file":"FactuurDetailPage.d.ts","sourceRoot":"","sources":["../../src/components/FactuurDetailPage.tsx"],"names":[],"mappings":"AAoDA,wBAAgB,iBAAiB,gCA+ahC"}
@@ -1,32 +1,107 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useCallback, useEffect, useState } from "react";
3
- import { Link, useParams } from "@tanstack/react-router";
4
- import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, toast, } from "@flowselections/core";
5
- import { ArrowLeft } from "lucide-react";
6
- import { fetchInvoice, fetchInvoiceItems, formatCents, KIND_LABELS, SOURCE_LABELS, STATUS_LABELS, updateInvoiceStatus, } from "../lib/facturen";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { DEFAULT_VAT_RATE } from "../lib/config";
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";
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";
8
+ import { InvoiceA4Preview } from "./InvoiceA4Preview";
9
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
10
+ const isValidEmail = (value) => EMAIL_PATTERN.test(value.trim());
7
11
  export function FactuurDetailPage() {
8
12
  const { id } = useParams({ from: "/_authenticated/facturen-installatietechniek/detail/$id" });
13
+ const search = useSearch({ from: "/_authenticated/facturen-installatietechniek/detail/$id" });
14
+ const navigate = useNavigate();
9
15
  const [invoice, setInvoice] = useState(null);
10
- const [items, setItems] = useState([]);
16
+ const [lines, setLines] = useState([]);
17
+ const [details, setDetails] = useState(emptyInvoiceDetails);
11
18
  const [loading, setLoading] = useState(true);
12
19
  const [saving, setSaving] = useState(false);
20
+ const [editing, setEditing] = useState(false);
21
+ const [logoUrl, setLogoUrl] = useState(null);
22
+ const [sendOpen, setSendOpen] = useState(false);
23
+ const [mail, setMail] = useState({ to: "", subject: "", message: "" });
24
+ const [mailToTouched, setMailToTouched] = useState(false);
25
+ const [knownEmail, setKnownEmail] = useState("");
26
+ const [confirmOpen, setConfirmOpen] = useState(false);
27
+ const [sender, setSender] = useState(null);
28
+ const [attachPdf, setAttachPdf] = useState(true);
29
+ const [preparedPdf, setPreparedPdf] = useState("");
30
+ const [preparingPdf, setPreparingPdf] = useState(false);
31
+ const [siblingInvoices, setSiblingInvoices] = useState(null);
32
+ const [siblingsLoading, setSiblingsLoading] = useState(true);
33
+ const siblingRetryRef = useRef(false);
34
+ const sheetRef = useRef(null);
35
+ const autoOpenedRef = useRef(false);
36
+ const loadSeqRef = useRef(0);
13
37
  const load = useCallback(async () => {
38
+ const seq = ++loadSeqRef.current;
14
39
  setLoading(true);
15
40
  try {
16
41
  const [inv, rows] = await Promise.all([fetchInvoice(id), fetchInvoiceItems(id)]);
42
+ if (seq !== loadSeqRef.current)
43
+ return;
44
+ const logo = await getInvoiceLogoUrl(inv?.branding_snapshot?.logo_path);
45
+ if (seq !== loadSeqRef.current)
46
+ return;
17
47
  setInvoice(inv);
18
- setItems(rows);
48
+ setDetails(detailsFromRecord(inv));
49
+ const safeRows = Array.isArray(rows) ? rows : [];
50
+ setLines(safeRows.map((r) => itemToEditLine(r, Number(inv?.vat_rate ?? DEFAULT_VAT_RATE))));
51
+ setLogoUrl(logo);
19
52
  }
20
53
  catch {
21
- toast.error("Factuur kon niet worden geladen");
54
+ if (seq === loadSeqRef.current)
55
+ toast.error("Factuur kon niet worden geladen");
22
56
  }
23
57
  finally {
24
- setLoading(false);
58
+ if (seq === loadSeqRef.current)
59
+ setLoading(false);
25
60
  }
26
61
  }, [id]);
27
62
  useEffect(() => {
28
63
  void load();
29
64
  }, [load]);
65
+ const loadSiblings = useCallback(async () => {
66
+ setSiblingsLoading(true);
67
+ try {
68
+ const rows = await fetchInvoices();
69
+ setSiblingInvoices(Array.isArray(rows) ? rows : []);
70
+ }
71
+ catch {
72
+ setSiblingInvoices(null);
73
+ }
74
+ finally {
75
+ setSiblingsLoading(false);
76
+ }
77
+ }, []);
78
+ useEffect(() => {
79
+ void loadSiblings();
80
+ }, [loadSiblings]);
81
+ // Als de huidige factuur nog niet in de lijst staat (net aangemaakt), één keer opnieuw ophalen.
82
+ useEffect(() => {
83
+ if (siblingsLoading || !siblingInvoices || siblingRetryRef.current)
84
+ return;
85
+ if (siblingInvoices.some((i) => i.id === id))
86
+ return;
87
+ siblingRetryRef.current = true;
88
+ void loadSiblings();
89
+ }, [siblingInvoices, siblingsLoading, id, loadSiblings]);
90
+ const listIndex = siblingInvoices ? siblingInvoices.findIndex((i) => i.id === id) : -1;
91
+ // Lijst is nieuwste eerst: vorige op de lijst = rij erboven, volgende = rij eronder.
92
+ const prevInvoice = listIndex > 0 ? siblingInvoices[listIndex - 1] : undefined;
93
+ const nextInvoice = listIndex >= 0 && siblingInvoices && listIndex < siblingInvoices.length - 1
94
+ ? siblingInvoices[listIndex + 1]
95
+ : undefined;
96
+ const goToSibling = (targetId) => {
97
+ if (!targetId)
98
+ return;
99
+ navigate({
100
+ to: "/facturen-installatietechniek/detail/$id",
101
+ params: { id: targetId },
102
+ search: { send: false },
103
+ });
104
+ };
30
105
  const changeStatus = async (status) => {
31
106
  setSaving(true);
32
107
  try {
@@ -41,11 +116,147 @@ export function FactuurDetailPage() {
41
116
  setSaving(false);
42
117
  }
43
118
  };
44
- if (loading) {
119
+ const save = async () => {
120
+ setSaving(true);
121
+ try {
122
+ await updateInvoiceFull(id, details, lines, invoice?.note ?? null);
123
+ toast.success("Factuur opgeslagen");
124
+ setEditing(false);
125
+ await load();
126
+ }
127
+ catch (e) {
128
+ toast.error(e instanceof Error ? e.message : "Opslaan mislukt");
129
+ }
130
+ finally {
131
+ setSaving(false);
132
+ }
133
+ };
134
+ const pdfFilename = `Factuur-${invoice?.invoice_number ?? "concept"}.pdf`;
135
+ const openSend = async () => {
136
+ const num = invoice?.invoice_number ?? "";
137
+ const company = invoice?.branding_snapshot?.company_name ?? "";
138
+ let storedEmail = "";
139
+ try {
140
+ storedEmail = (await fetchCustomerEmail(invoice?.customer_id)) ?? "";
141
+ }
142
+ catch {
143
+ storedEmail = "";
144
+ }
145
+ setKnownEmail(storedEmail.trim());
146
+ const customerEmail = invoice?.sent_to || storedEmail;
147
+ setMail({
148
+ to: customerEmail,
149
+ subject: `Factuur ${num}${company ? ` van ${company}` : ""}`,
150
+ message: `Beste ${details.customer_attention || details.customer_name || "klant"},\n\nIn de bijlage vindt u factuur ${num}.\n\nMet vriendelijke groet,\n${company}`,
151
+ });
152
+ setSender(null);
153
+ setAttachPdf(true);
154
+ setPreparedPdf("");
155
+ setMailToTouched(false);
156
+ fetchInvoiceEmailPurpose().then(fetchSenderForPurpose).then(setSender).catch(() => setSender(null));
157
+ setSendOpen(true);
158
+ if (!sheetRef.current)
159
+ return;
160
+ setPreparingPdf(true);
161
+ try {
162
+ const pdf = await elementToPdfBase64(sheetRef.current);
163
+ setPreparedPdf(pdf);
164
+ downloadPdfBase64(pdf, pdfFilename);
165
+ }
166
+ catch (error) {
167
+ toast.error(error instanceof Error ? error.message : "De factuur-PDF kon niet worden aangemaakt");
168
+ }
169
+ finally {
170
+ setPreparingPdf(false);
171
+ }
172
+ };
173
+ useEffect(() => {
174
+ autoOpenedRef.current = false;
175
+ }, [id]);
176
+ useEffect(() => {
177
+ if (loading || !invoice || !search.send || autoOpenedRef.current)
178
+ return;
179
+ autoOpenedRef.current = true;
180
+ void openSend();
181
+ void navigate({
182
+ to: "/facturen-installatietechniek/detail/$id",
183
+ params: { id },
184
+ search: { send: false },
185
+ replace: true,
186
+ });
187
+ }, [id, invoice, loading, navigate, search.send]);
188
+ const send = () => {
189
+ if (!isValidEmail(mail.to)) {
190
+ toast.error("Vul een geldig e-mailadres in");
191
+ return;
192
+ }
193
+ if (!invoice)
194
+ return;
195
+ if (!attachPdf || !preparedPdf) {
196
+ toast.error("Vink de PDF-bijlage aan voordat u de factuur verzendt");
197
+ return;
198
+ }
199
+ if (mail.to.trim().toLowerCase() !== knownEmail.toLowerCase()) {
200
+ setConfirmOpen(true);
201
+ return;
202
+ }
203
+ void doSend();
204
+ };
205
+ const doSend = async () => {
206
+ setConfirmOpen(false);
207
+ if (!invoice || !isValidEmail(mail.to) || !attachPdf || !preparedPdf)
208
+ return;
209
+ const targetInvoice = nextInvoice ?? prevInvoice;
210
+ setSaving(true);
211
+ try {
212
+ const queued = await sendInvoiceEmail({
213
+ invoiceId: id,
214
+ to: mail.to.trim(),
215
+ subject: mail.subject,
216
+ message: mail.message,
217
+ pdfBase64: preparedPdf,
218
+ filename: pdfFilename,
219
+ });
220
+ try {
221
+ await registerInvoiceSend(id, queued.outboxId);
222
+ }
223
+ catch (e) {
224
+ toast.warning(e instanceof Error ? e.message : "De e-mail staat in de wachtrij, maar de factuur kon niet als verzonden worden geregistreerd");
225
+ }
226
+ toast.success(`Factuur verzonden naar ${mail.to.trim()}`, {
227
+ description: "De factuur staat nu op Verzonden. De e-mailmodule verwerkt de verzending met de PDF als bijlage.",
228
+ });
229
+ setSendOpen(false);
230
+ if (targetInvoice) {
231
+ navigate({
232
+ to: "/facturen-installatietechniek/detail/$id",
233
+ params: { id: targetInvoice.id },
234
+ search: { send: false },
235
+ });
236
+ }
237
+ else {
238
+ navigate({ to: "/facturen-installatietechniek" });
239
+ }
240
+ }
241
+ catch (e) {
242
+ toast.error(e instanceof Error ? e.message : "Verzenden mislukt");
243
+ }
244
+ finally {
245
+ setSaving(false);
246
+ }
247
+ };
248
+ const changeSendOpen = (nextOpen) => {
249
+ setSendOpen(nextOpen);
250
+ if (!nextOpen) {
251
+ setPreparedPdf("");
252
+ }
253
+ };
254
+ if (loading && !invoice)
45
255
  return _jsx("main", { className: "p-6 text-sm text-muted-foreground", children: "Laden\u2026" });
46
- }
47
256
  if (!invoice) {
48
257
  return (_jsxs("main", { className: "space-y-4 p-6", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "Factuur niet gevonden." }), _jsx(Button, { asChild: true, variant: "outline", children: _jsx(Link, { to: "/facturen-installatietechniek", children: "Terug naar overzicht" }) })] }));
49
258
  }
50
- return (_jsxs("main", { className: "space-y-6 p-6", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-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}` : ""] }), _jsx("div", { className: "ml-auto w-48", children: _jsxs(Select, { value: invoice.status, onValueChange: (v) => changeStatus(v), disabled: saving, children: [_jsx(SelectTrigger, { children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: Object.entries(STATUS_LABELS).map(([value, label]) => (_jsx(SelectItem, { value: value, children: label }, value))) })] }) })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: "Klant" }) }), _jsx(CardContent, { className: "text-base", children: invoice.customer_name ?? "—" })] }), _jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: "Factuurdatum" }) }), _jsx(CardContent, { className: "text-base", children: new Date(invoice.invoice_date).toLocaleDateString("nl-NL") })] }), _jsxs(Card, { children: [_jsx(CardHeader, { className: "pb-2", children: _jsx(CardTitle, { className: "text-sm font-medium text-muted-foreground", children: "Totaal incl. btw" }) }), _jsx(CardContent, { className: "text-base font-semibold", children: formatCents(invoice.total_cents) })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Regels" }) }), _jsxs(CardContent, { className: "space-y-4", children: [items.length === 0 ? (_jsx("p", { className: "text-sm text-muted-foreground", children: "Deze factuur heeft geen regels." })) : (_jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Omschrijving" }), _jsx(TableHead, { children: "Soort" }), _jsx(TableHead, { className: "text-right", children: "Aantal" }), _jsx(TableHead, { children: "Eenheid" }), _jsx(TableHead, { className: "text-right", children: "Stukprijs" }), _jsx(TableHead, { className: "text-right", children: "Totaal" })] }) }), _jsx(TableBody, { children: items.map((item) => (_jsxs(TableRow, { children: [_jsx(TableCell, { children: item.description }), _jsx(TableCell, { children: KIND_LABELS[item.kind] ?? item.kind }), _jsx(TableCell, { className: "text-right", children: Number(item.quantity) }), _jsx(TableCell, { children: item.unit }), _jsx(TableCell, { className: "text-right", children: formatCents(item.unit_price_cents) }), _jsx(TableCell, { className: "text-right", children: formatCents(item.total_cents) })] }, item.id))) })] })), _jsxs("div", { className: "ml-auto w-64 space-y-1 text-sm", children: [_jsxs("div", { className: "flex justify-between", children: [_jsx("span", { className: "text-muted-foreground", children: "Subtotaal" }), _jsx("span", { children: formatCents(invoice.subtotal_cents) })] }), _jsxs("div", { className: "flex justify-between", children: [_jsxs("span", { className: "text-muted-foreground", children: ["Btw ", Number(invoice.vat_rate), "%"] }), _jsx("span", { children: formatCents(invoice.vat_cents) })] }), _jsxs("div", { className: "flex justify-between font-semibold", children: [_jsx("span", { children: "Totaal" }), _jsx("span", { children: formatCents(invoice.total_cents) })] })] })] })] }), invoice.note ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { children: "Notitie" }) }), _jsx(CardContent, { className: "text-sm", children: invoice.note })] })) : null] }));
259
+ const isConcept = invoice.status === "concept";
260
+ const wasSent = Boolean(invoice.sent_at);
261
+ 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: [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 || !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" })] })] }) })] }));
51
262
  }