@flowselections/floriday-klanten-module 1.0.13 → 1.0.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-lib/components/CustomerAvatar.js +1 -1
- package/dist-lib/components/klanten/ImportKlanten.d.ts +2 -0
- package/dist-lib/components/klanten/ImportKlanten.d.ts.map +1 -0
- package/dist-lib/components/klanten/ImportKlanten.js +438 -0
- package/dist-lib/components/klanten/KlantenOverzicht.d.ts.map +1 -1
- package/dist-lib/components/klanten/KlantenOverzicht.js +2 -2
- package/dist-lib/components/klanten/NetwerkPagina.d.ts.map +1 -1
- package/dist-lib/components/klanten/NetwerkPagina.js +38 -9
- package/dist-lib/components/klanten/NieuweKlant.d.ts.map +1 -1
- package/dist-lib/components/klanten/NieuweKlant.js +10 -1
- package/dist-lib/index.d.ts +1 -0
- package/dist-lib/index.d.ts.map +1 -1
- package/dist-lib/index.js +1 -0
- package/dist-lib/integrations/supabase/auth-middleware.d.ts +372 -1
- package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/client.d.ts +372 -1
- package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/client.server.d.ts +372 -1
- package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.d.ts +376 -1
- package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
- package/dist-lib/lib/accounting.functions.d.ts +1116 -3
- package/dist-lib/lib/accounting.functions.d.ts.map +1 -1
- package/dist-lib/lib/customer-analytics.functions.d.ts +372 -1
- package/dist-lib/lib/customer-analytics.functions.d.ts.map +1 -1
- package/dist-lib/lib/customer-schemas.d.ts +147 -0
- package/dist-lib/lib/customer-schemas.d.ts.map +1 -1
- package/dist-lib/lib/customer-schemas.js +106 -7
- package/dist-lib/lib/customers.functions.d.ts +14184 -15
- package/dist-lib/lib/customers.functions.d.ts.map +1 -1
- package/dist-lib/lib/customers.functions.js +130 -23
- package/dist-lib/lib/delivery-locations.functions.d.ts +1488 -4
- package/dist-lib/lib/delivery-locations.functions.d.ts.map +1 -1
- package/dist-lib/lib/external-customers.functions.d.ts +1488 -4
- package/dist-lib/lib/external-customers.functions.d.ts.map +1 -1
- package/dist-lib/lib/floriday-discounts.functions.d.ts +744 -2
- package/dist-lib/lib/floriday-discounts.functions.d.ts.map +1 -1
- package/dist-lib/lib/local-discounts.functions.d.ts +2604 -7
- package/dist-lib/lib/local-discounts.functions.d.ts.map +1 -1
- package/dist-lib/lib/overdue-invoices.functions.d.ts +372 -1
- package/dist-lib/lib/overdue-invoices.functions.d.ts.map +1 -1
- package/package.json +10 -3
|
@@ -12,7 +12,7 @@ function initials(name) {
|
|
|
12
12
|
}
|
|
13
13
|
export function CustomerAvatar({ logoUrl, name, size = "md", className, }) {
|
|
14
14
|
const sizeClass = size === "sm" ? "h-8 w-8 text-xs" : size === "lg" ? "h-16 w-16 text-lg" : "h-10 w-10 text-sm";
|
|
15
|
-
return (_jsx("div", { className: cn("relative shrink-0 overflow-hidden rounded-
|
|
15
|
+
return (_jsx("div", { className: cn("relative shrink-0 overflow-hidden rounded-md bg-white flex items-center justify-center font-medium text-muted-foreground border border-border", sizeClass, className), children: logoUrl ? (_jsx("img", { src: logoUrl, alt: name ?? "", className: "h-full w-full object-contain p-1", onError: (e) => {
|
|
16
16
|
e.currentTarget.style.display = "none";
|
|
17
17
|
} })) : (_jsx("span", { children: initials(name) })) }));
|
|
18
18
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ImportKlanten.d.ts","sourceRoot":"","sources":["../../../src/components/klanten/ImportKlanten.tsx"],"names":[],"mappings":"AA4MA,wBAAgB,aAAa,gCAmc5B"}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import { useEffect, useMemo, useState } from "react";
|
|
4
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
6
|
+
import { Button, Card, CardContent, CardHeader, CardTitle, Input, Label, toast, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge, Checkbox, } from "@flowselections/core";
|
|
7
|
+
import { ArrowLeft, Upload, Download, FileText, CheckCircle2, AlertTriangle, XCircle } from "lucide-react";
|
|
8
|
+
import * as XLSX from "xlsx";
|
|
9
|
+
import { bulkImportCustomers } from "../../lib/customers.functions";
|
|
10
|
+
import { customerImportRowSchema } from "../../lib/customer-schemas";
|
|
11
|
+
// Alleen `company_name` is écht verplicht om te kunnen importeren. Overige
|
|
12
|
+
// velden mogen leeg zijn — ontbrekende gegevens worden na de import getoond
|
|
13
|
+
// als waarschuwing zodat je ze later kunt aanvullen.
|
|
14
|
+
const REQUIRED = ["company_name"];
|
|
15
|
+
const RECOMMENDED_FIELDS = [
|
|
16
|
+
{ key: "email", label: "E-mailadres" },
|
|
17
|
+
{ key: "street", label: "Straat" },
|
|
18
|
+
{ key: "house_number", label: "Huisnummer" },
|
|
19
|
+
{ key: "country", label: "Land" },
|
|
20
|
+
];
|
|
21
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
22
|
+
// Alle interne doelvelden die de importer ondersteunt (voor handmatige mapping)
|
|
23
|
+
const TARGET_FIELDS = [
|
|
24
|
+
{ key: "company_name", label: "Bedrijfsnaam", required: true },
|
|
25
|
+
{ key: "email", label: "E-mailadres" },
|
|
26
|
+
{ key: "street", label: "Straat" },
|
|
27
|
+
{ key: "house_number", label: "Huisnummer" },
|
|
28
|
+
{ key: "country", label: "Land" },
|
|
29
|
+
{ key: "customer_number", label: "Klantnummer" },
|
|
30
|
+
{ key: "language", label: "Taal" },
|
|
31
|
+
{ key: "website", label: "Website" },
|
|
32
|
+
{ key: "billing_email", label: "E-mailadres voor facturering" },
|
|
33
|
+
{ key: "address_type", label: "Adrestype" },
|
|
34
|
+
{ key: "address_line1", label: "Adresregel 1" },
|
|
35
|
+
{ key: "address_line2", label: "Adresregel 2" },
|
|
36
|
+
{ key: "postal_code", label: "Postcode" },
|
|
37
|
+
{ key: "city", label: "Plaats" },
|
|
38
|
+
{ key: "icp_country_code", label: "ICP landcode" },
|
|
39
|
+
{ key: "attention_to", label: "T.a.v." },
|
|
40
|
+
{ key: "phone", label: "Telefoonnummer" },
|
|
41
|
+
{ key: "fax", label: "Faxnummer" },
|
|
42
|
+
{ key: "vat_number", label: "Btw-nummer" },
|
|
43
|
+
{ key: "coc_number", label: "KvK-nummer" },
|
|
44
|
+
{ key: "contact_person", label: "Contactpersoon" },
|
|
45
|
+
{ key: "customer_class", label: "Klantklasse" },
|
|
46
|
+
{ key: "notes", label: "Notities" },
|
|
47
|
+
{ key: "is_active", label: "Actief" },
|
|
48
|
+
{ key: "bank_name", label: "Bank - Naam" },
|
|
49
|
+
{ key: "bank_account_holder", label: "Bank - Rekeninghouder" },
|
|
50
|
+
{ key: "bank_account_number", label: "Bank - Rekeningnummer" },
|
|
51
|
+
{ key: "bank_iban", label: "Bank - IBAN" },
|
|
52
|
+
{ key: "bank_bic", label: "Bank - BIC" },
|
|
53
|
+
{ key: "bank_national_code", label: "Bank - Nationale bankcode" },
|
|
54
|
+
{ key: "bank_address_line1", label: "Bank - Adresregel 1" },
|
|
55
|
+
{ key: "bank_address_line2", label: "Bank - Adresregel 2" },
|
|
56
|
+
{ key: "bank_house_number", label: "Bank - Huisnummer" },
|
|
57
|
+
{ key: "bank_postal_code", label: "Bank - Postcode" },
|
|
58
|
+
{ key: "bank_city", label: "Bank - Plaats" },
|
|
59
|
+
{ key: "bank_state", label: "Bank - Staat" },
|
|
60
|
+
{ key: "bank_country", label: "Bank - Land" },
|
|
61
|
+
{ key: "discount_percentage", label: "Kortingspercentage" },
|
|
62
|
+
{ key: "discount_days", label: "Aantal kortingsdagen" },
|
|
63
|
+
{ key: "payment_term_days", label: "Aantal vervaldagen" },
|
|
64
|
+
{ key: "vat_display", label: "Btw-weergave op factuur" },
|
|
65
|
+
];
|
|
66
|
+
// Mapping van herkende (Nederlandse of Engelse) CSV-kolomnamen -> intern veld.
|
|
67
|
+
// Alles wordt case-insensitive vergeleken.
|
|
68
|
+
const HEADER_ALIASES = {
|
|
69
|
+
// basis
|
|
70
|
+
"nummer": "customer_number", "customer_number": "customer_number",
|
|
71
|
+
"naam": "company_name", "bedrijfsnaam": "company_name", "company_name": "company_name",
|
|
72
|
+
"taal": "language", "language": "language",
|
|
73
|
+
"website": "website",
|
|
74
|
+
"e-mailadres voor facturering": "billing_email", "billing_email": "billing_email",
|
|
75
|
+
"e-mailadres": "email", "email": "email", "e-mail": "email",
|
|
76
|
+
"adrestype": "address_type", "address_type": "address_type",
|
|
77
|
+
"adresregel 1": "address_line1", "adresregel1": "address_line1", "address_line1": "address_line1",
|
|
78
|
+
"adresregel 2": "address_line2", "adresregel2": "address_line2", "address_line2": "address_line2",
|
|
79
|
+
"straat": "street", "street": "street",
|
|
80
|
+
"huisnummer": "house_number", "house_number": "house_number",
|
|
81
|
+
"postcode": "postal_code", "postal_code": "postal_code",
|
|
82
|
+
"plaats": "city", "stad": "city", "city": "city",
|
|
83
|
+
"land": "country", "country": "country",
|
|
84
|
+
"icp land code": "icp_country_code", "icp landcode": "icp_country_code", "icp_country_code": "icp_country_code",
|
|
85
|
+
"t.a.v.": "attention_to", "tav": "attention_to", "attention_to": "attention_to",
|
|
86
|
+
"telefoonnummer": "phone", "telefoon": "phone", "phone": "phone",
|
|
87
|
+
"faxnummer": "fax", "fax": "fax",
|
|
88
|
+
"btw-nummer": "vat_number", "btw nummer": "vat_number", "vat_number": "vat_number",
|
|
89
|
+
"kvk-nummer": "coc_number", "kvk nummer": "coc_number", "coc_number": "coc_number",
|
|
90
|
+
"contactpersoon": "contact_person", "contact_person": "contact_person",
|
|
91
|
+
"klantklasse": "customer_class", "customer_class": "customer_class",
|
|
92
|
+
"notities": "notes", "notes": "notes",
|
|
93
|
+
// bank
|
|
94
|
+
"bank - naam": "bank_name", "bank naam": "bank_name", "bank_name": "bank_name",
|
|
95
|
+
"bank - rekeninghouder": "bank_account_holder", "bank_account_holder": "bank_account_holder",
|
|
96
|
+
"bank - rekeningnummer": "bank_account_number", "bank_account_number": "bank_account_number",
|
|
97
|
+
"bank - iban": "bank_iban", "bank iban": "bank_iban", "bank_iban": "bank_iban", "iban": "bank_iban",
|
|
98
|
+
"bank - bic": "bank_bic", "bic": "bank_bic", "bank_bic": "bank_bic",
|
|
99
|
+
"bank - nationale bankcode": "bank_national_code", "bank_national_code": "bank_national_code",
|
|
100
|
+
"bank - adresregel 1": "bank_address_line1", "bank_address_line1": "bank_address_line1",
|
|
101
|
+
"bank - adresregel 2": "bank_address_line2", "bank_address_line2": "bank_address_line2",
|
|
102
|
+
"bank - huisnummer": "bank_house_number", "bank_house_number": "bank_house_number",
|
|
103
|
+
"bank - postcode": "bank_postal_code", "bank_postal_code": "bank_postal_code",
|
|
104
|
+
"bank - plaats": "bank_city", "bank_city": "bank_city",
|
|
105
|
+
"bank - staat": "bank_state", "bank_state": "bank_state",
|
|
106
|
+
"bank - land": "bank_country", "bank_country": "bank_country",
|
|
107
|
+
// facturering
|
|
108
|
+
"kortingspercentage": "discount_percentage", "discount_percentage": "discount_percentage",
|
|
109
|
+
"aantal kortingsdagen": "discount_days", "discount_days": "discount_days",
|
|
110
|
+
"aantal vervaldagen": "payment_term_days", "payment_term_days": "payment_term_days",
|
|
111
|
+
"btw-weergave op factuur": "vat_display", "btw weergave op factuur": "vat_display", "vat_display": "vat_display",
|
|
112
|
+
// status
|
|
113
|
+
"actief": "is_active", "is_active": "is_active",
|
|
114
|
+
};
|
|
115
|
+
const TEMPLATE_HEADERS = [
|
|
116
|
+
"Nummer", "Naam", "Taal", "Website", "E-mailadres voor facturering",
|
|
117
|
+
"Adrestype", "Adresregel 1", "Adresregel 2", "Postcode", "Plaats", "Land",
|
|
118
|
+
"ICP land code", "T.a.v.", "Telefoonnummer", "Faxnummer",
|
|
119
|
+
"Btw-nummer", "KvK-nummer", "E-mailadres",
|
|
120
|
+
"Bank - Naam", "Bank - Rekeninghouder", "Bank - Rekeningnummer",
|
|
121
|
+
"Bank - IBAN", "Bank - BIC", "Bank - Nationale bankcode",
|
|
122
|
+
"Bank - Adresregel 1", "Bank - Adresregel 2", "Bank - Huisnummer",
|
|
123
|
+
"Bank - Postcode", "Bank - Plaats", "Bank - Staat", "Bank - Land",
|
|
124
|
+
"Kortingspercentage", "Aantal kortingsdagen", "Aantal vervaldagen",
|
|
125
|
+
"Btw-weergave op factuur",
|
|
126
|
+
"Straat", "Huisnummer", "Contactpersoon", "Klantklasse", "Notities", "Actief",
|
|
127
|
+
];
|
|
128
|
+
const TEMPLATE_CSV = TEMPLATE_HEADERS.map((h) => `"${h}"`).join(",") + "\n" +
|
|
129
|
+
`"K-001","Voorbeeld B.V.","nl","https://voorbeeld.nl","facturen@voorbeeld.nl","Bezoek","Hoofdstraat 12","","1234AB","Amsterdam","Nederland","NL","Jan Jansen","+31612345678","","NL123456789B01","12345678","info@voorbeeld.nl","ING","Voorbeeld B.V.","","NL91INGB0002445588","INGBNL2A","","Adres 1","","1","1000AA","Amsterdam","","Nederland","2","14","30","Exclusief","Hoofdstraat","12","Jan Jansen","Groothandel","","true"\n`;
|
|
130
|
+
function parseCSV(text) {
|
|
131
|
+
const rows = [];
|
|
132
|
+
let cur = [];
|
|
133
|
+
let field = "";
|
|
134
|
+
let inQuotes = false;
|
|
135
|
+
for (let i = 0; i < text.length; i++) {
|
|
136
|
+
const ch = text[i];
|
|
137
|
+
if (inQuotes) {
|
|
138
|
+
if (ch === '"') {
|
|
139
|
+
if (text[i + 1] === '"') {
|
|
140
|
+
field += '"';
|
|
141
|
+
i++;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
inQuotes = false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else
|
|
148
|
+
field += ch;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
if (ch === '"')
|
|
152
|
+
inQuotes = true;
|
|
153
|
+
else if (ch === "," || ch === ";" || ch === "\t") {
|
|
154
|
+
cur.push(field);
|
|
155
|
+
field = "";
|
|
156
|
+
}
|
|
157
|
+
else if (ch === "\n" || ch === "\r") {
|
|
158
|
+
if (field !== "" || cur.length > 0) {
|
|
159
|
+
cur.push(field);
|
|
160
|
+
rows.push(cur);
|
|
161
|
+
cur = [];
|
|
162
|
+
field = "";
|
|
163
|
+
}
|
|
164
|
+
if (ch === "\r" && text[i + 1] === "\n")
|
|
165
|
+
i++;
|
|
166
|
+
}
|
|
167
|
+
else
|
|
168
|
+
field += ch;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (field !== "" || cur.length > 0) {
|
|
172
|
+
cur.push(field);
|
|
173
|
+
rows.push(cur);
|
|
174
|
+
}
|
|
175
|
+
if (rows.length === 0)
|
|
176
|
+
return { headers: [], rows: [] };
|
|
177
|
+
const headers = rows[0].map((h) => h.trim());
|
|
178
|
+
const dataRows = rows.slice(1)
|
|
179
|
+
.filter((r) => r.some((c) => c.trim() !== ""))
|
|
180
|
+
.map((r) => {
|
|
181
|
+
const obj = {};
|
|
182
|
+
headers.forEach((h, idx) => { obj[h] = (r[idx] ?? "").trim(); });
|
|
183
|
+
return obj;
|
|
184
|
+
});
|
|
185
|
+
return { headers, rows: dataRows };
|
|
186
|
+
}
|
|
187
|
+
function toBool(v) {
|
|
188
|
+
if (v === undefined || v === "")
|
|
189
|
+
return true;
|
|
190
|
+
return !["false", "0", "nee", "no"].includes(v.toLowerCase());
|
|
191
|
+
}
|
|
192
|
+
function rowsFromXlsx(buf) {
|
|
193
|
+
const wb = XLSX.read(buf, { type: "array" });
|
|
194
|
+
const sheet = wb.Sheets[wb.SheetNames[0]];
|
|
195
|
+
if (!sheet)
|
|
196
|
+
return { headers: [], rows: [] };
|
|
197
|
+
const matrix = XLSX.utils.sheet_to_json(sheet, {
|
|
198
|
+
header: 1, raw: false, defval: "",
|
|
199
|
+
});
|
|
200
|
+
if (matrix.length === 0)
|
|
201
|
+
return { headers: [], rows: [] };
|
|
202
|
+
const headers = matrix[0].map((h) => String(h ?? "").trim());
|
|
203
|
+
const dataRows = matrix.slice(1)
|
|
204
|
+
.filter((r) => r.some((c) => String(c ?? "").trim() !== ""))
|
|
205
|
+
.map((r) => {
|
|
206
|
+
const obj = {};
|
|
207
|
+
headers.forEach((h, idx) => { obj[h] = String(r[idx] ?? "").trim(); });
|
|
208
|
+
return obj;
|
|
209
|
+
});
|
|
210
|
+
return { headers, rows: dataRows };
|
|
211
|
+
}
|
|
212
|
+
export function ImportKlanten() {
|
|
213
|
+
const navigate = useNavigate();
|
|
214
|
+
const importFn = useServerFn(bulkImportCustomers);
|
|
215
|
+
const queryClient = useQueryClient();
|
|
216
|
+
const [sourceHeaders, setSourceHeaders] = useState([]);
|
|
217
|
+
const [sourceRows, setSourceRows] = useState([]);
|
|
218
|
+
const [mapping, setMapping] = useState({});
|
|
219
|
+
const [rows, setRows] = useState([]);
|
|
220
|
+
const [fileName, setFileName] = useState("");
|
|
221
|
+
const [skipDuplicates, setSkipDuplicates] = useState(true);
|
|
222
|
+
const [result, setResult] = useState(null);
|
|
223
|
+
const mut = useMutation({
|
|
224
|
+
mutationFn: (validRows) => importFn({ data: { rows: validRows, skipDuplicates } }),
|
|
225
|
+
onSuccess: (r) => {
|
|
226
|
+
setResult(r);
|
|
227
|
+
if (r.created > 0)
|
|
228
|
+
toast.success(`${r.created} klant${r.created === 1 ? "" : "en"} geïmporteerd`);
|
|
229
|
+
if (r.failed > 0)
|
|
230
|
+
toast.error(`${r.failed} rij${r.failed === 1 ? "" : "en"} mislukt`);
|
|
231
|
+
if (r.created > 0) {
|
|
232
|
+
queryClient.invalidateQueries({ queryKey: ["customers-local"] });
|
|
233
|
+
queryClient.invalidateQueries({ queryKey: ["customers-external"] });
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
onError: (e) => toast.error(e instanceof Error ? e.message : "Import mislukt"),
|
|
237
|
+
});
|
|
238
|
+
const validCount = useMemo(() => rows.filter((r) => r.data && r.errors.length === 0).length, [rows]);
|
|
239
|
+
const invalidCount = rows.length - validCount;
|
|
240
|
+
const handleFile = async (file) => {
|
|
241
|
+
setResult(null);
|
|
242
|
+
setRows([]);
|
|
243
|
+
setFileName(file.name);
|
|
244
|
+
const isXlsx = /\.xlsx?$/i.test(file.name)
|
|
245
|
+
|| file.type.includes("spreadsheetml")
|
|
246
|
+
|| file.type.includes("ms-excel");
|
|
247
|
+
const { headers, rows: raw } = isXlsx
|
|
248
|
+
? rowsFromXlsx(await file.arrayBuffer())
|
|
249
|
+
: parseCSV(await file.text());
|
|
250
|
+
if (raw.length === 0) {
|
|
251
|
+
toast.error("Geen rijen gevonden in bestand");
|
|
252
|
+
setSourceHeaders([]);
|
|
253
|
+
setSourceRows([]);
|
|
254
|
+
setMapping({});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Auto-map: probeer elke bronkolom via HEADER_ALIASES te matchen aan een intern veld.
|
|
258
|
+
const auto = {};
|
|
259
|
+
const used = new Set();
|
|
260
|
+
for (const h of headers) {
|
|
261
|
+
const key = h.trim().toLowerCase();
|
|
262
|
+
const target = HEADER_ALIASES[key];
|
|
263
|
+
if (target && !used.has(target)) {
|
|
264
|
+
auto[h] = target;
|
|
265
|
+
used.add(target);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
auto[h] = "";
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
setSourceHeaders(headers);
|
|
272
|
+
setSourceRows(raw);
|
|
273
|
+
setMapping(auto);
|
|
274
|
+
};
|
|
275
|
+
const buildParsed = (srcRows, map) => {
|
|
276
|
+
return srcRows.map((sr) => {
|
|
277
|
+
// Herbouw rij op basis van huidige mapping
|
|
278
|
+
const r = {};
|
|
279
|
+
for (const [srcHeader, target] of Object.entries(map)) {
|
|
280
|
+
if (!target)
|
|
281
|
+
continue;
|
|
282
|
+
const val = sr[srcHeader];
|
|
283
|
+
if (val !== undefined && val !== "")
|
|
284
|
+
r[target] = val;
|
|
285
|
+
}
|
|
286
|
+
// Fallback: als straat ontbreekt maar adresregel 1 is gevuld, gebruik die.
|
|
287
|
+
const street = r.street || r.address_line1 || "";
|
|
288
|
+
const houseNumber = r.house_number || "";
|
|
289
|
+
// E-mail sanitair: alleen doorlaten als het qua vorm lijkt op een
|
|
290
|
+
// e-mailadres. Ongeldige waarden worden weggelaten en als
|
|
291
|
+
// waarschuwing gerapporteerd.
|
|
292
|
+
const rawEmail = (r.email || "").trim();
|
|
293
|
+
const emailValid = rawEmail !== "" && EMAIL_RE.test(rawEmail);
|
|
294
|
+
const email = emailValid ? rawEmail : "";
|
|
295
|
+
const candidate = {
|
|
296
|
+
company_name: r.company_name ?? "",
|
|
297
|
+
email,
|
|
298
|
+
street,
|
|
299
|
+
house_number: houseNumber,
|
|
300
|
+
country: r.country ?? "",
|
|
301
|
+
vat_number: r.vat_number ?? "",
|
|
302
|
+
contact_person: r.contact_person ?? "",
|
|
303
|
+
phone: r.phone ?? "",
|
|
304
|
+
postal_code: r.postal_code ?? "",
|
|
305
|
+
city: r.city ?? "",
|
|
306
|
+
customer_class: r.customer_class ?? "",
|
|
307
|
+
notes: r.notes ?? "",
|
|
308
|
+
is_active: toBool(r.is_active),
|
|
309
|
+
customer_number: r.customer_number ?? "",
|
|
310
|
+
language: r.language ?? "",
|
|
311
|
+
website: r.website ?? "",
|
|
312
|
+
billing_email: r.billing_email ?? "",
|
|
313
|
+
address_type: r.address_type ?? "",
|
|
314
|
+
address_line1: r.address_line1 ?? "",
|
|
315
|
+
address_line2: r.address_line2 ?? "",
|
|
316
|
+
icp_country_code: r.icp_country_code ?? "",
|
|
317
|
+
attention_to: r.attention_to ?? "",
|
|
318
|
+
fax: r.fax ?? "",
|
|
319
|
+
coc_number: r.coc_number ?? "",
|
|
320
|
+
bank_name: r.bank_name ?? "",
|
|
321
|
+
bank_account_holder: r.bank_account_holder ?? "",
|
|
322
|
+
bank_account_number: r.bank_account_number ?? "",
|
|
323
|
+
bank_iban: r.bank_iban ?? "",
|
|
324
|
+
bank_bic: r.bank_bic ?? "",
|
|
325
|
+
bank_national_code: r.bank_national_code ?? "",
|
|
326
|
+
bank_address_line1: r.bank_address_line1 ?? "",
|
|
327
|
+
bank_address_line2: r.bank_address_line2 ?? "",
|
|
328
|
+
bank_house_number: r.bank_house_number ?? "",
|
|
329
|
+
bank_postal_code: r.bank_postal_code ?? "",
|
|
330
|
+
bank_city: r.bank_city ?? "",
|
|
331
|
+
bank_state: r.bank_state ?? "",
|
|
332
|
+
bank_country: r.bank_country ?? "",
|
|
333
|
+
discount_percentage: r.discount_percentage ?? "",
|
|
334
|
+
discount_days: r.discount_days ?? "",
|
|
335
|
+
payment_term_days: r.payment_term_days ?? "",
|
|
336
|
+
vat_display: r.vat_display ?? "",
|
|
337
|
+
};
|
|
338
|
+
const warnings = [];
|
|
339
|
+
for (const f of RECOMMENDED_FIELDS) {
|
|
340
|
+
const v = candidate[f.key];
|
|
341
|
+
if (v === undefined || v === null || v === "") {
|
|
342
|
+
warnings.push(`${f.label} ontbreekt`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (rawEmail !== "" && !emailValid) {
|
|
346
|
+
warnings.push("E-mailadres ongeldig — leeg gelaten");
|
|
347
|
+
}
|
|
348
|
+
const res = customerImportRowSchema.safeParse(candidate);
|
|
349
|
+
if (res.success)
|
|
350
|
+
return { raw: r, data: res.data, errors: [], warnings };
|
|
351
|
+
return {
|
|
352
|
+
raw: r,
|
|
353
|
+
data: null,
|
|
354
|
+
errors: res.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`),
|
|
355
|
+
warnings,
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
};
|
|
359
|
+
// Preview automatisch bijwerken wanneer bron of mapping verandert.
|
|
360
|
+
useEffect(() => {
|
|
361
|
+
if (sourceRows.length === 0) {
|
|
362
|
+
setRows([]);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
setRows(buildParsed(sourceRows, mapping));
|
|
366
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
367
|
+
}, [sourceRows, mapping]);
|
|
368
|
+
const downloadTemplateCsv = () => {
|
|
369
|
+
const blob = new Blob([TEMPLATE_CSV], { type: "text/csv;charset=utf-8" });
|
|
370
|
+
const url = URL.createObjectURL(blob);
|
|
371
|
+
const a = document.createElement("a");
|
|
372
|
+
a.href = url;
|
|
373
|
+
a.download = "klanten-import-template.csv";
|
|
374
|
+
a.click();
|
|
375
|
+
URL.revokeObjectURL(url);
|
|
376
|
+
};
|
|
377
|
+
const downloadTemplateXlsx = () => {
|
|
378
|
+
const sampleRow = {
|
|
379
|
+
"Nummer": "K-001", "Naam": "Voorbeeld B.V.", "Taal": "nl",
|
|
380
|
+
"Website": "https://voorbeeld.nl",
|
|
381
|
+
"E-mailadres voor facturering": "facturen@voorbeeld.nl",
|
|
382
|
+
"Adrestype": "Bezoek", "Adresregel 1": "Hoofdstraat 12", "Adresregel 2": "",
|
|
383
|
+
"Postcode": "1234AB", "Plaats": "Amsterdam", "Land": "Nederland",
|
|
384
|
+
"ICP land code": "NL", "T.a.v.": "Jan Jansen",
|
|
385
|
+
"Telefoonnummer": "+31612345678", "Faxnummer": "",
|
|
386
|
+
"Btw-nummer": "NL123456789B01", "KvK-nummer": "12345678",
|
|
387
|
+
"E-mailadres": "info@voorbeeld.nl",
|
|
388
|
+
"Bank - Naam": "ING", "Bank - Rekeninghouder": "Voorbeeld B.V.",
|
|
389
|
+
"Bank - Rekeningnummer": "", "Bank - IBAN": "NL91INGB0002445588",
|
|
390
|
+
"Bank - BIC": "INGBNL2A", "Bank - Nationale bankcode": "",
|
|
391
|
+
"Bank - Adresregel 1": "Adres 1", "Bank - Adresregel 2": "",
|
|
392
|
+
"Bank - Huisnummer": "1", "Bank - Postcode": "1000AA",
|
|
393
|
+
"Bank - Plaats": "Amsterdam", "Bank - Staat": "", "Bank - Land": "Nederland",
|
|
394
|
+
"Kortingspercentage": "2", "Aantal kortingsdagen": "14",
|
|
395
|
+
"Aantal vervaldagen": "30", "Btw-weergave op factuur": "Exclusief",
|
|
396
|
+
"Straat": "Hoofdstraat", "Huisnummer": "12",
|
|
397
|
+
"Contactpersoon": "Jan Jansen", "Klantklasse": "Groothandel",
|
|
398
|
+
"Notities": "", "Actief": "true",
|
|
399
|
+
};
|
|
400
|
+
const ws = XLSX.utils.json_to_sheet([sampleRow], { header: TEMPLATE_HEADERS });
|
|
401
|
+
const wb = XLSX.utils.book_new();
|
|
402
|
+
XLSX.utils.book_append_sheet(wb, ws, "Klanten");
|
|
403
|
+
XLSX.writeFile(wb, "klanten-import-template.xlsx");
|
|
404
|
+
};
|
|
405
|
+
const runImport = () => {
|
|
406
|
+
const valid = rows.filter((r) => r.data && r.errors.length === 0).map((r) => r.data);
|
|
407
|
+
if (valid.length === 0) {
|
|
408
|
+
toast.error("Geen geldige rijen om te importeren");
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
mut.mutate(valid);
|
|
412
|
+
};
|
|
413
|
+
return (_jsxs("main", { className: "p-6 max-w-5xl mx-auto space-y-4", children: [_jsxs(Button, { variant: "ghost", size: "sm", onClick: () => navigate({ to: "/klanten" }), children: [_jsx(ArrowLeft, { className: "h-4 w-4 mr-2" }), " Terug"] }), _jsxs("div", { className: "flex items-start justify-between gap-4 flex-wrap", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: "Klanten importeren" }), _jsxs("p", { className: "text-sm text-muted-foreground mt-1", children: ["Upload een CSV of Excel om meerdere klanten tegelijk toe te voegen. Alleen ", _jsx("strong", { children: "bedrijfsnaam" }), " is verplicht \u2014 ontbrekende gegevens zoals e-mail, adres of land worden na de import als waarschuwing getoond zodat je ze later kunt aanvullen."] })] }), _jsxs("div", { className: "flex gap-2", children: [_jsxs(Button, { variant: "outline", onClick: downloadTemplateXlsx, children: [_jsx(Download, { className: "h-4 w-4 mr-2" }), " Template (.xlsx)"] }), _jsxs(Button, { variant: "outline", onClick: downloadTemplateCsv, children: [_jsx(Download, { className: "h-4 w-4 mr-2" }), " Template (.csv)"] })] })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "1. Bestand kiezen" }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Label, { htmlFor: "csvfile", className: "sr-only", children: "Excel- of CSV-bestand" }), _jsx(Input, { id: "csvfile", type: "file", accept: ".csv,.xlsx,.xls,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel", onChange: (e) => { const f = e.target.files?.[0]; if (f)
|
|
414
|
+
handleFile(f); }, className: "max-w-sm" }), fileName && (_jsxs("span", { className: "inline-flex items-center gap-2 text-sm text-muted-foreground", children: [_jsx(FileText, { className: "h-4 w-4" }), " ", fileName] }))] }), _jsxs("label", { className: "inline-flex items-center gap-2 text-sm", children: [_jsx(Checkbox, { checked: skipDuplicates, onCheckedChange: (v) => setSkipDuplicates(v === true) }), "Duplicaten overslaan (op e-mail, BTW of vergelijkbare bedrijfsnaam)"] })] })] }), sourceHeaders.length > 0 && (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base", children: ["2. Velden koppelen (", sourceHeaders.length, " kolommen)"] }), _jsx("div", { className: "text-xs text-muted-foreground", children: "Kies per bronkolom naar welk klantveld deze moet worden ge\u00EFmporteerd." })] }), _jsx(CardContent, { className: "p-0", children: _jsx("div", { className: "max-h-[420px] overflow-auto", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Bronkolom (uit bestand)" }), _jsx(TableHead, { children: "Voorbeeldwaarde" }), _jsx(TableHead, { className: "w-[280px]", children: "Koppelen aan klantveld" })] }) }), _jsx(TableBody, { children: sourceHeaders.map((h) => {
|
|
415
|
+
const sample = sourceRows.find((r) => r[h])?.[h] ?? "";
|
|
416
|
+
return (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "text-sm font-medium", children: h || _jsx("span", { className: "text-muted-foreground italic", children: "(zonder naam)" }) }), _jsx(TableCell, { className: "text-xs text-muted-foreground truncate max-w-[280px]", children: sample }), _jsx(TableCell, { children: _jsxs("select", { className: "w-full h-9 rounded-md border border-input bg-background px-2 text-sm", value: mapping[h] ?? "", onChange: (e) => setMapping((m) => ({ ...m, [h]: e.target.value })), children: [_jsx("option", { value: "", children: "\u2014 Negeren \u2014" }), TARGET_FIELDS.map((f) => (_jsxs("option", { value: f.key, children: [f.label, f.required ? " *" : ""] }, f.key)))] }) })] }, h));
|
|
417
|
+
}) })] }) }) }), _jsxs("div", { className: "flex items-center justify-between gap-2 p-4 border-t", children: [_jsxs("div", { className: "text-xs text-muted-foreground", children: ["Verplicht: ", TARGET_FIELDS.filter((f) => f.required).map((f) => f.label).join(", ")] }), _jsx(Button, { variant: "outline", onClick: () => {
|
|
418
|
+
setSourceHeaders([]);
|
|
419
|
+
setSourceRows([]);
|
|
420
|
+
setMapping({});
|
|
421
|
+
setRows([]);
|
|
422
|
+
setFileName("");
|
|
423
|
+
setResult(null);
|
|
424
|
+
}, children: "Wissen" })] })] })), rows.length > 0 && (_jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base", children: ["3. Voorbeeld (", rows.length, " rijen)"] }), _jsxs("div", { className: "flex items-center gap-2 text-sm", children: [_jsxs(Badge, { variant: "secondary", className: "bg-success/10 text-success", children: [_jsx(CheckCircle2, { className: "h-3 w-3 mr-1" }), " ", validCount, " geldig"] }), rows.filter((r) => r.errors.length === 0 && r.warnings.length > 0).length > 0 && (_jsxs(Badge, { variant: "secondary", className: "bg-warning/10 text-warning", children: [_jsx(AlertTriangle, { className: "h-3 w-3 mr-1" }), rows.filter((r) => r.errors.length === 0 && r.warnings.length > 0).length, " met waarschuwingen"] })), invalidCount > 0 && (_jsxs(Badge, { variant: "secondary", className: "bg-destructive/10 text-destructive", children: [_jsx(XCircle, { className: "h-3 w-3 mr-1" }), " ", invalidCount, " ongeldig"] }))] })] }), _jsx(CardContent, { className: "p-0", children: _jsx("div", { className: "max-h-[420px] overflow-auto", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { className: "w-10", children: "#" }), _jsx(TableHead, { children: "Bedrijf" }), _jsx(TableHead, { children: "E-mail" }), _jsx(TableHead, { children: "Plaats" }), _jsx(TableHead, { children: "Land" }), _jsx(TableHead, { children: "Status" })] }) }), _jsx(TableBody, { children: rows.map((r, i) => (_jsxs(TableRow, { className: r.errors.length > 0
|
|
425
|
+
? "bg-destructive/5"
|
|
426
|
+
: r.warnings.length > 0
|
|
427
|
+
? "bg-warning/5"
|
|
428
|
+
: "", children: [_jsx(TableCell, { className: "text-xs text-muted-foreground", children: i + 1 }), _jsx(TableCell, { className: "text-sm", children: r.raw.company_name || _jsx("span", { className: "text-muted-foreground", children: "\u2014" }) }), _jsx(TableCell, { className: "text-sm", children: r.raw.email || _jsx("span", { className: "text-muted-foreground", children: "\u2014" }) }), _jsx(TableCell, { className: "text-sm", children: r.raw.city || _jsx("span", { className: "text-muted-foreground", children: "\u2014" }) }), _jsx(TableCell, { className: "text-sm", children: r.raw.country || _jsx("span", { className: "text-muted-foreground", children: "\u2014" }) }), _jsx(TableCell, { children: r.errors.length > 0 ? (_jsxs("span", { className: "inline-flex items-center gap-1 text-xs text-destructive", title: r.errors.join("\n"), children: [_jsx(AlertTriangle, { className: "h-3.5 w-3.5" }), " ", r.errors[0]] })) : r.warnings.length > 0 ? (_jsxs("span", { className: "inline-flex items-center gap-1 text-xs text-warning", title: r.warnings.join("\n"), children: [_jsx(AlertTriangle, { className: "h-3.5 w-3.5" }), r.warnings.length === 1
|
|
429
|
+
? r.warnings[0]
|
|
430
|
+
: `${r.warnings.length} waarschuwingen`] })) : (_jsxs("span", { className: "inline-flex items-center gap-1 text-xs text-success", children: [_jsx(CheckCircle2, { className: "h-3.5 w-3.5" }), " OK"] })) })] }, i))) })] }) }) }), _jsxs("div", { className: "flex justify-end gap-2 p-4 border-t", children: [_jsx(Button, { variant: "outline", onClick: () => {
|
|
431
|
+
setRows([]);
|
|
432
|
+
setFileName("");
|
|
433
|
+
setResult(null);
|
|
434
|
+
setSourceHeaders([]);
|
|
435
|
+
setSourceRows([]);
|
|
436
|
+
setMapping({});
|
|
437
|
+
}, children: "Wissen" }), _jsxs(Button, { onClick: runImport, disabled: mut.isPending || validCount === 0, children: [_jsx(Upload, { className: "h-4 w-4 mr-2" }), mut.isPending ? "Importeren..." : `Importeer ${validCount} klant${validCount === 1 ? "" : "en"}`] })] })] })), result && (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Resultaat" }) }), _jsxs(CardContent, { className: "space-y-3", children: [_jsxs("div", { className: "flex gap-2 flex-wrap", children: [_jsxs(Badge, { className: "bg-success/10 text-success", children: [result.created, " aangemaakt"] }), _jsxs(Badge, { className: "bg-warning/10 text-warning", children: [result.skipped, " overgeslagen"] }), _jsxs(Badge, { className: "bg-destructive/10 text-destructive", children: [result.failed, " mislukt"] })] }), result.errors.length > 0 && (_jsx("div", { className: "max-h-64 overflow-auto border rounded-md", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { className: "w-10", children: "#" }), _jsx(TableHead, { children: "Bedrijf" }), _jsx(TableHead, { children: "Reden" })] }) }), _jsx(TableBody, { children: result.errors.map((e, i) => (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "text-xs", children: e.row }), _jsx(TableCell, { className: "text-sm", children: e.company_name }), _jsx(TableCell, { className: "text-sm text-muted-foreground", children: e.reason })] }, i))) })] }) })), _jsx("div", { className: "flex justify-end", children: _jsx(Button, { onClick: () => navigate({ to: "/klanten" }), children: "Naar klantenlijst" }) })] })] }))] }));
|
|
438
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"KlantenOverzicht.d.ts","sourceRoot":"","sources":["../../../src/components/klanten/KlantenOverzicht.tsx"],"names":[],"mappings":"AAwCA,wBAAgB,gBAAgB,
|
|
1
|
+
{"version":3,"file":"KlantenOverzicht.d.ts","sourceRoot":"","sources":["../../../src/components/klanten/KlantenOverzicht.tsx"],"names":[],"mappings":"AAwCA,wBAAgB,gBAAgB,gCA2Z/B"}
|
|
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
4
4
|
import { useServerFn } from "@tanstack/react-start";
|
|
5
5
|
import { useMemo, useState } from "react";
|
|
6
6
|
import { Button, Input, Card, CardContent, Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsList, TabsTrigger, Skeleton, } from "@flowselections/core";
|
|
7
|
-
import { Plus, Search, Network, Users, UserCheck, UserX, AlertCircle, X, ArrowUpDown, Mail, MapPin, ChevronLeft, ChevronRight, } from "lucide-react";
|
|
7
|
+
import { Plus, Search, Network, Users, UserCheck, UserX, AlertCircle, X, ArrowUpDown, Mail, MapPin, Upload, ChevronLeft, ChevronRight, } from "lucide-react";
|
|
8
8
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
9
9
|
import { listCustomers, listCustomerClasses } from "../../lib/customers.functions";
|
|
10
10
|
import { listExternalCustomers } from "../../lib/external-customers.functions";
|
|
@@ -157,7 +157,7 @@ export function KlantenOverzicht() {
|
|
|
157
157
|
activeFilterChips.push({ key: "co", label: `Land: ${country}`, clear: () => { setCountry("__all__"); resetPage(); } });
|
|
158
158
|
const toggleSort = (col) => setSort((s) => (s.col === col ? { col, dir: s.dir === "asc" ? "desc" : "asc" } : { col, dir: "asc" }));
|
|
159
159
|
const lastUpdated = externalData?.last_fetched_at ?? null;
|
|
160
|
-
return (_jsx(TooltipPrimitive.Provider, { delayDuration: 200, children: _jsxs("main", { className: "p-6 space-y-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4 flex-wrap", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: "Klanten" }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: "Beheer en doorzoek al je klanten." })] }), _jsxs("div", { className: "flex gap-2", children: [_jsxs(Button, { variant: "outline", onClick: () => navigate({ to: "/klanten/netwerk" }), children: [_jsx(Network, { className: "h-4 w-4 mr-2" }), " Klanten toevoegen uit netwerk"] }), _jsxs(Button, { onClick: () => navigate({ to: "/klanten/nieuw" }), children: [_jsx(Plus, { className: "h-4 w-4 mr-2" }), " Nieuwe klant"] })] })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3", children: [_jsx(StatCard, { icon: _jsx(Users, { className: "h-5 w-5" }), tint: "muted", label: "Alle klanten", value: totals.all, tooltip: "Totaal aantal klanten (lokaal + gekoppelde bronnen).", active: view === "all", onClick: () => { setView("all"); resetPage(); } }), _jsx(StatCard, { icon: _jsx(UserCheck, { className: "h-5 w-5" }), tint: "success", label: "Actieve klanten", value: totals.active, tooltip: "Klanten met minimaal \u00E9\u00E9n bestelling in de afgelopen 12 maanden.", active: view === "active", onClick: () => { setView("active"); resetPage(); } }), _jsx(StatCard, { icon: _jsx(UserX, { className: "h-5 w-5" }), tint: "warning", label: "Inactieve klanten", value: totals.inactive, tooltip: "Klanten zonder omzet in de afgelopen 12 maanden.", active: view === "inactive", onClick: () => { setView("inactive"); resetPage(); } }), _jsx(StatCard, { icon: _jsx(AlertCircle, { className: "h-5 w-5" }), tint: "destructive", label: "Achterstallige betalingen", value: totals.overdue, tooltip: (overdueData
|
|
160
|
+
return (_jsx(TooltipPrimitive.Provider, { delayDuration: 200, children: _jsxs("main", { className: "p-6 space-y-5", children: [_jsxs("div", { className: "flex items-start justify-between gap-4 flex-wrap", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: "Klanten" }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: "Beheer en doorzoek al je klanten." })] }), _jsxs("div", { className: "flex gap-2", children: [_jsxs(Button, { variant: "outline", onClick: () => navigate({ to: "/klanten/netwerk" }), children: [_jsx(Network, { className: "h-4 w-4 mr-2" }), " Klanten toevoegen uit netwerk"] }), _jsxs(Button, { variant: "outline", onClick: () => navigate({ to: "/klanten/import" }), children: [_jsx(Upload, { className: "h-4 w-4 mr-2" }), " Importeren"] }), _jsxs(Button, { onClick: () => navigate({ to: "/klanten/nieuw" }), children: [_jsx(Plus, { className: "h-4 w-4 mr-2" }), " Nieuwe klant"] })] })] }), _jsxs("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3", children: [_jsx(StatCard, { icon: _jsx(Users, { className: "h-5 w-5" }), tint: "muted", label: "Alle klanten", value: totals.all, tooltip: "Totaal aantal klanten (lokaal + gekoppelde bronnen).", active: view === "all", onClick: () => { setView("all"); resetPage(); } }), _jsx(StatCard, { icon: _jsx(UserCheck, { className: "h-5 w-5" }), tint: "success", label: "Actieve klanten", value: totals.active, tooltip: "Klanten met minimaal \u00E9\u00E9n bestelling in de afgelopen 12 maanden.", active: view === "active", onClick: () => { setView("active"); resetPage(); } }), _jsx(StatCard, { icon: _jsx(UserX, { className: "h-5 w-5" }), tint: "warning", label: "Inactieve klanten", value: totals.inactive, tooltip: "Klanten zonder omzet in de afgelopen 12 maanden.", active: view === "inactive", onClick: () => { setView("inactive"); resetPage(); } }), _jsx(StatCard, { icon: _jsx(AlertCircle, { className: "h-5 w-5" }), tint: "destructive", label: "Achterstallige betalingen", value: totals.overdue, tooltip: (overdueData
|
|
161
161
|
? `Klanten met openstaande facturen waarvan de vervaldatum is verstreken.\nTotaal openstaand: ${formatEuro(overdueData.totals.amount)} · ${overdueData.totals.invoices} facturen\nBron: Twinfield & Exact · bijgewerkt ${relativeTime(overdueData.as_of)}`
|
|
162
162
|
: "Klanten met openstaande facturen waarvan de vervaldatum is verstreken."), active: view === "overdue", onClick: () => { setView("overdue"); resetPage(); } })] }), _jsxs(Card, { children: [_jsx("div", { className: "border-b px-4 pt-3", children: _jsx(Tabs, { value: view, onValueChange: (v) => { setView(v); resetPage(); }, children: _jsxs(TabsList, { className: "flex-wrap", children: [_jsxs(TabsTrigger, { value: "all", children: ["Alle ", _jsx("span", { className: "ml-1.5 text-xs text-muted-foreground", children: totals.all ?? 0 })] }), _jsxs(TabsTrigger, { value: "active", children: ["Actief ", _jsx("span", { className: "ml-1.5 text-xs text-muted-foreground", children: totals.active ?? 0 })] }), _jsxs(TabsTrigger, { value: "inactive", children: ["Inactief ", _jsx("span", { className: "ml-1.5 text-xs text-muted-foreground", children: totals.inactive ?? 0 })] }), _jsxs(TabsTrigger, { value: "overdue", children: ["Achterstallig ", _jsx("span", { className: "ml-1.5 text-xs text-muted-foreground", children: totals.overdue ?? 0 })] })] }) }) }), _jsxs("div", { className: "flex items-center gap-2 flex-wrap p-4 border-b", children: [_jsxs("div", { className: "relative flex-1 min-w-[240px] max-w-md", children: [_jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), _jsx(Input, { placeholder: "Zoek op bedrijf, contactpersoon of e-mail...", value: search, onChange: (e) => { setSearch(e.target.value); resetPage(); }, className: "pl-9" })] }), _jsxs(Select, { value: customerClass, onValueChange: (v) => { setCustomerClass(v); resetPage(); }, children: [_jsx(SelectTrigger, { className: "w-40", children: _jsx(SelectValue, { placeholder: "Klasse" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "__all__", children: "Alle klassen" }), (classData?.classes ?? []).map((c) => (_jsx(SelectItem, { value: c, children: c }, c)))] })] }), _jsxs(Select, { value: country, onValueChange: (v) => { setCountry(v); resetPage(); }, children: [_jsx(SelectTrigger, { className: "w-36", children: _jsx(SelectValue, { placeholder: "Land" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "__all__", children: "Alle landen" }), countries.map((c) => (_jsx(SelectItem, { value: c, children: c }, c)))] })] })] }), activeFilterChips.length > 0 && (_jsxs("div", { className: "flex items-center gap-2 flex-wrap px-4 py-2 border-b bg-muted/30", children: [activeFilterChips.map((chip) => (_jsxs("button", { type: "button", onClick: chip.clear, className: "inline-flex items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1 text-xs hover:bg-muted", children: [chip.label, " ", _jsx(X, { className: "h-3 w-3" })] }, chip.key))), _jsx("button", { type: "button", onClick: () => { setSearch(""); setCustomerClass("__all__"); setCountry("__all__"); resetPage(); }, className: "text-xs text-muted-foreground hover:text-foreground underline", children: "Alles wissen" })] })), _jsx(CardContent, { className: "p-0", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: _jsx(SortHeader, { label: "Bedrijf", col: "company_name", sort: sort, onClick: toggleSort }) }), _jsx(TableHead, { children: "Contact" }), _jsx(TableHead, { children: _jsx(SortHeader, { label: "Klasse", col: "customer_class", sort: sort, onClick: toggleSort }) }), _jsx(TableHead, { children: _jsx(SortHeader, { label: "Land", col: "country", sort: sort, onClick: toggleSort }) }), _jsx(TableHead, { children: "Status" })] }) }), _jsx(TableBody, { children: isLoading ? (Array.from({ length: 6 }).map((_, i) => (_jsx(TableRow, { children: Array.from({ length: 5 }).map((__, j) => (_jsx(TableCell, { className: "py-4", children: _jsx(Skeleton, { className: "h-4 w-full max-w-[160px]" }) }, j))) }, i)))) : pageRows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 5, children: _jsx(EmptyState, { onAdd: () => navigate({ to: "/klanten/nieuw" }) }) }) })) : pageRows.map((c) => {
|
|
163
163
|
const secondary = c.commercial_name && c.commercial_name !== c.company_name
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NetwerkPagina.d.ts","sourceRoot":"","sources":["../../../src/components/klanten/NetwerkPagina.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"NetwerkPagina.d.ts","sourceRoot":"","sources":["../../../src/components/klanten/NetwerkPagina.tsx"],"names":[],"mappings":"AA8BA,wBAAgB,aAAa,gCAwO5B"}
|
|
@@ -1,23 +1,52 @@
|
|
|
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
2
|
import { Link } from "@tanstack/react-router";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { useServerFn } from "@tanstack/react-start";
|
|
5
5
|
import { useState } from "react";
|
|
6
6
|
import { Input, Card, CardContent, Badge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Switch, Label, } from "@flowselections/core";
|
|
7
|
-
import { ArrowLeft, Search } from "lucide-react";
|
|
7
|
+
import { ArrowLeft, Search, MapPin, Globe } from "lucide-react";
|
|
8
8
|
import { listFloridayNetwork } from "../../lib/customers.functions";
|
|
9
|
+
import { CustomerAvatar } from "../CustomerAvatar";
|
|
10
|
+
const TYPE_OPTIONS = [
|
|
11
|
+
{ value: "CUSTOMER", label: "Klant" },
|
|
12
|
+
{ value: "AGENT", label: "Agent" },
|
|
13
|
+
{ value: "SUPPLIER", label: "Leverancier" },
|
|
14
|
+
{ value: "CARRIER", label: "Vervoerder" },
|
|
15
|
+
{ value: "OTHER", label: "Overig" },
|
|
16
|
+
];
|
|
9
17
|
export function NetwerkPagina() {
|
|
10
18
|
const [search, setSearch] = useState("");
|
|
11
|
-
const [hideUnknown, setHideUnknown] = useState(
|
|
19
|
+
const [hideUnknown, setHideUnknown] = useState(false);
|
|
20
|
+
const [types, setTypes] = useState(["CUSTOMER", "AGENT"]);
|
|
21
|
+
const [includeExpired, setIncludeExpired] = useState(false);
|
|
12
22
|
const list = useServerFn(listFloridayNetwork);
|
|
13
23
|
const { data, isLoading } = useQuery({
|
|
14
|
-
queryKey: ["floriday-network", search],
|
|
15
|
-
queryFn: () => list({ data: { search } }),
|
|
24
|
+
queryKey: ["floriday-network", search, types, includeExpired],
|
|
25
|
+
queryFn: () => list({ data: { search, types, includeExpired, limit: 1000 } }),
|
|
16
26
|
});
|
|
17
27
|
const all = data?.organizations ?? [];
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
: all;
|
|
28
|
+
const named = all.filter((o) => o.organization_name && o.organization_name.trim() !== "");
|
|
29
|
+
const orgs = hideUnknown ? named : all;
|
|
21
30
|
const hiddenCount = all.length - orgs.length;
|
|
22
|
-
|
|
31
|
+
const totalMatching = data?.total ?? all.length;
|
|
32
|
+
const truncated = !!data?.truncated;
|
|
33
|
+
const toggleType = (value) => {
|
|
34
|
+
setTypes((prev) => prev.includes(value) ? prev.filter((t) => t !== value) : [...prev, value]);
|
|
35
|
+
};
|
|
36
|
+
return (_jsxs("main", { className: "p-6 space-y-4", children: [_jsx("div", { className: "flex items-center justify-between gap-4", children: _jsxs("div", { children: [_jsxs(Link, { to: "/klanten", className: "text-sm text-muted-foreground hover:text-foreground inline-flex items-center gap-1", children: [_jsx(ArrowLeft, { className: "h-3 w-3" }), " Terug naar klanten"] }), _jsx("h1", { className: "text-2xl font-semibold mt-1", children: "Klanten toevoegen uit netwerk" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Doorzoek alle organisaties die via het netwerk beschikbaar zijn." })] }) }), _jsxs("div", { className: "relative max-w-md", children: [_jsx(Search, { className: "absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" }), _jsx(Input, { placeholder: "Zoek op organisatienaam of GLN...", value: search, onChange: (e) => setSearch(e.target.value), className: "pl-9" })] }), _jsx("div", { className: "flex items-center gap-2 flex-wrap", children: TYPE_OPTIONS.map((opt) => {
|
|
37
|
+
const active = types.includes(opt.value);
|
|
38
|
+
return (_jsx("button", { type: "button", onClick: () => toggleType(opt.value), className: "inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs transition-colors " +
|
|
39
|
+
(active
|
|
40
|
+
? "bg-primary text-primary-foreground border-primary"
|
|
41
|
+
: "bg-card text-muted-foreground border-border hover:bg-muted"), children: opt.label }, opt.value));
|
|
42
|
+
}) }), _jsxs("div", { className: "flex items-center gap-6 flex-wrap text-sm", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "include-expired", checked: includeExpired, onCheckedChange: setIncludeExpired }), _jsx(Label, { htmlFor: "include-expired", className: "cursor-pointer", children: "Toon be\u00EBindigde relaties" })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "hide-unknown", checked: hideUnknown, onCheckedChange: setHideUnknown }), _jsx(Label, { htmlFor: "hide-unknown", className: "cursor-pointer", children: "Verberg organisaties zonder naam" }), hideUnknown && hiddenCount > 0 ? (_jsxs("span", { className: "text-muted-foreground", children: ["(", hiddenCount, " verborgen)"] })) : null] })] }), _jsx("div", { className: "text-xs text-muted-foreground", children: isLoading ? ("Laden…") : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-medium text-foreground", children: orgs.length.toLocaleString("nl-NL") }), " ", "weergegeven van", " ", _jsx("span", { className: "font-medium text-foreground", children: totalMatching.toLocaleString("nl-NL") }), " ", "organisaties \u00B7 bron: Floriday", truncated && (_jsx("span", { className: "ml-1 text-warning", children: "(verfijn de zoekopdracht om alles te zien)" }))] })) }), _jsx(Card, { children: _jsx(CardContent, { className: "p-0", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Organisatie" }), _jsx(TableHead, { children: "GLN" }), _jsx(TableHead, { children: "Adres" }), _jsx(TableHead, { children: "Land" }), _jsx(TableHead, { children: "Type" }), _jsx(TableHead, { children: "Bijgewerkt" })] }) }), _jsx(TableBody, { children: isLoading ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 6, className: "text-center py-8 text-muted-foreground", children: "Laden..." }) })) : orgs.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: 6, className: "text-center py-8 text-muted-foreground", children: "Geen organisaties gevonden." }) })) : (orgs.map((o) => {
|
|
43
|
+
const displayName = o.organization_name && o.organization_name.trim() !== ""
|
|
44
|
+
? o.organization_name
|
|
45
|
+
: null;
|
|
46
|
+
const addressBits = [
|
|
47
|
+
o.address_line,
|
|
48
|
+
[o.postal_code, o.city].filter(Boolean).join(" "),
|
|
49
|
+
].filter((x) => x && String(x).trim() !== "");
|
|
50
|
+
return (_jsxs(TableRow, { children: [_jsx(TableCell, { className: "py-3", children: _jsxs("div", { className: "flex items-center gap-3 min-w-0", children: [_jsx(CustomerAvatar, { logoUrl: o.logo_url, name: displayName ?? "?", size: "sm" }), _jsxs("div", { className: "min-w-0", children: [_jsxs("div", { className: "font-medium truncate flex items-center gap-2", children: [displayName ? (_jsx("span", { className: "truncate", children: displayName })) : (_jsx("span", { className: "italic text-muted-foreground", children: "Onbekende organisatie" })), o.is_expired && (_jsx(Badge, { variant: "outline", className: "text-[10px] uppercase tracking-wide", children: "be\u00EBindigd" }))] }), o.commercial_name && (_jsx("div", { className: "text-xs text-muted-foreground truncate", children: o.commercial_name })), o.website && (_jsxs("a", { href: o.website.startsWith("http") ? o.website : `https://${o.website}`, target: "_blank", rel: "noreferrer", className: "text-xs text-muted-foreground inline-flex items-center gap-1 hover:underline", children: [_jsx(Globe, { className: "h-3 w-3" }), " ", o.website] }))] })] }) }), _jsx(TableCell, { children: o.gln ? _jsx(Badge, { variant: "outline", children: o.gln }) : "—" }), _jsx(TableCell, { className: "text-sm", children: addressBits.length ? (_jsx("div", { className: "min-w-0", children: _jsxs("div", { className: "inline-flex items-start gap-1 text-muted-foreground", children: [_jsx(MapPin, { className: "h-3 w-3 mt-0.5 shrink-0" }), _jsx("span", { className: "truncate", children: addressBits.join(", ") })] }) })) : ("—") }), _jsx(TableCell, { children: o.country ?? "—" }), _jsx(TableCell, { className: "text-xs text-muted-foreground uppercase tracking-wide", children: o.organization_type ?? "—" }), _jsx(TableCell, { className: "text-muted-foreground text-sm", children: new Date(o.fetched_at).toLocaleDateString("nl-NL") })] }, `${o.connection_id}-${o.organization_id}`));
|
|
51
|
+
})) })] }) }) })] }));
|
|
23
52
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NieuweKlant.d.ts","sourceRoot":"","sources":["../../../src/components/klanten/NieuweKlant.tsx"],"names":[],"mappings":"AAgBA,wBAAgB,WAAW,
|
|
1
|
+
{"version":3,"file":"NieuweKlant.d.ts","sourceRoot":"","sources":["../../../src/components/klanten/NieuweKlant.tsx"],"names":[],"mappings":"AAgBA,wBAAgB,WAAW,gCAiN1B"}
|