@flowselections/floriday-voorraad 1.0.55 → 1.0.59
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/voorraad/StockBulkButtons.d.ts.map +1 -1
- package/dist-lib/components/voorraad/StockBulkButtons.js +343 -311
- package/dist-lib/components/voorraad/StockIntegrityAlert.d.ts.map +1 -1
- package/dist-lib/components/voorraad/StockIntegrityAlert.js +34 -2
- package/dist-lib/integrations/supabase/types.d.ts +388 -2
- package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import * as XLSX from "xlsx";
|
|
4
|
-
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
5
|
-
import { Download, FileDown, Upload, Loader2 } from "lucide-react";
|
|
4
|
+
import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, toast, } from "@flowselections/core";
|
|
5
|
+
import { Download, FileDown, Upload, Loader2, Undo2, Play } from "lucide-react";
|
|
6
6
|
import { supabase } from "../../integrations/supabase/client";
|
|
7
7
|
import { emptyWizardData } from "../../lib/floriday-payload";
|
|
8
|
-
import { upsertLocalTradeItem, updateDraftMeta } from "../../hooks/useLocalTradeItems";
|
|
9
8
|
import { validateDraftForPublish } from "../../lib/floriday-import-validator";
|
|
10
|
-
import { floriday } from "../../lib/floriday-client";
|
|
11
9
|
import { ensureArticleCodes } from "../../lib/ensure-article-codes";
|
|
12
10
|
import { useVoorraadCustomFields } from "../../hooks/useVoorraadCustomFields";
|
|
13
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Voorraadimport in porties (Voorraad-module).
|
|
13
|
+
* De browser leest het bestand en controleert de rijen; de database verwerkt
|
|
14
|
+
* per portie (alles of niets) via stock_import_create / _process_chunk /
|
|
15
|
+
* _undo_chunk. Gereserveerde voorraad wordt nooit geïmporteerd; nieuwe
|
|
16
|
+
* artikelen worden niet naar Floriday gepubliceerd.
|
|
17
|
+
*/
|
|
14
18
|
const COLUMNS = [
|
|
15
19
|
"Artikelcode",
|
|
16
20
|
"Artikelnaam",
|
|
@@ -23,28 +27,18 @@ const COLUMNS = [
|
|
|
23
27
|
"Gereserveerde voorraad",
|
|
24
28
|
"Inkomende voorraad",
|
|
25
29
|
];
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
* zodra het artikel gekoppeld is (die sleutel activeert de push naar Floriday),
|
|
29
|
-
* anders `product:<uuid>`.
|
|
30
|
-
*/
|
|
31
|
-
function toKey(item) {
|
|
32
|
-
if (item.floridayId)
|
|
33
|
-
return item.floridayId;
|
|
34
|
-
if (item.tradeItemKey)
|
|
35
|
-
return item.tradeItemKey;
|
|
36
|
-
if (item.productId)
|
|
37
|
-
return `product:${item.productId}`;
|
|
38
|
-
if (item.localId)
|
|
39
|
-
return `product:${item.localId}`;
|
|
40
|
-
return item.id;
|
|
41
|
-
}
|
|
30
|
+
const ACTIVE = ["ontvangen", "bezig", "gestopt"];
|
|
31
|
+
const rpc = (fn, args) => supabase.rpc(fn, args);
|
|
42
32
|
function parseNumber(val) {
|
|
43
33
|
if (val === undefined || val === null || val === "")
|
|
44
34
|
return { value: null };
|
|
45
35
|
const n = typeof val === "number" ? val : Number(String(val).replace(",", "."));
|
|
46
36
|
if (!Number.isFinite(n))
|
|
47
37
|
return { value: null, error: "geen numerieke waarde" };
|
|
38
|
+
if (!Number.isInteger(n))
|
|
39
|
+
return { value: null, error: "alleen hele aantallen" };
|
|
40
|
+
if (Math.abs(n) > 2000000000)
|
|
41
|
+
return { value: null, error: "aantal te groot" };
|
|
48
42
|
return { value: n };
|
|
49
43
|
}
|
|
50
44
|
function productGroupForVbn(vbn) {
|
|
@@ -57,21 +51,28 @@ function productGroupForVbn(vbn) {
|
|
|
57
51
|
return "GARDEN_PLANT";
|
|
58
52
|
return null;
|
|
59
53
|
}
|
|
54
|
+
async function sha256(buf) {
|
|
55
|
+
const d = await crypto.subtle.digest("SHA-256", buf);
|
|
56
|
+
return Array.from(new Uint8Array(d)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
57
|
+
}
|
|
58
|
+
const STATUS_LABEL = {
|
|
59
|
+
ontvangen: "Ontvangen",
|
|
60
|
+
bezig: "Bezig",
|
|
61
|
+
gestopt: "Gestopt",
|
|
62
|
+
voltooid: "Voltooid",
|
|
63
|
+
ongedaan: "Ongedaan",
|
|
64
|
+
deels_ongedaan: "Deels ongedaan",
|
|
65
|
+
};
|
|
60
66
|
export function StockBulkButtons({ items, activeConnectionId, onImported, }) {
|
|
61
67
|
const fileRef = useRef(null);
|
|
62
68
|
const [open, setOpen] = useState(false);
|
|
63
|
-
const [
|
|
64
|
-
const [progress, setProgress] = useState(
|
|
65
|
-
const
|
|
66
|
-
const [
|
|
67
|
-
const [result, setResult] = useState(null);
|
|
69
|
+
const [busy, setBusy] = useState(null);
|
|
70
|
+
const [progress, setProgress] = useState(null);
|
|
71
|
+
const [prepared, setPrepared] = useState(null);
|
|
72
|
+
const [banner, setBanner] = useState(null);
|
|
68
73
|
const [pastRuns, setPastRuns] = useState([]);
|
|
69
74
|
const { fields: customFields } = useVoorraadCustomFields();
|
|
70
75
|
const templateFields = customFields.filter((f) => f.show_in_template);
|
|
71
|
-
/**
|
|
72
|
-
* Leest de waarde van een eigen veld uit een Excel-rij. Kolomkoppen worden
|
|
73
|
-
* tolerant gematcht: op label óf key, hoofdletter- en spatie-ongevoelig.
|
|
74
|
-
*/
|
|
75
76
|
const readCustomValues = useCallback((row) => {
|
|
76
77
|
const norm = (s) => s.toLowerCase().replace(/[\s_-]+/g, "");
|
|
77
78
|
const lookup = new Map();
|
|
@@ -97,7 +98,7 @@ export function StockBulkButtons({ items, activeConnectionId, onImported, }) {
|
|
|
97
98
|
const loadPastRuns = useCallback(async () => {
|
|
98
99
|
const { data, error } = await supabase
|
|
99
100
|
.from("stock_import_runs")
|
|
100
|
-
.select("id,filename,total_rows,updated_rows,created_rows,skipped_rows,
|
|
101
|
+
.select("id,filename,status,total_rows,updated_rows,created_rows,skipped_rows,total_chunks,done_chunks,undone_rows,undo_skipped_rows,created_at")
|
|
101
102
|
.order("created_at", { ascending: false })
|
|
102
103
|
.limit(10);
|
|
103
104
|
if (!error)
|
|
@@ -107,6 +108,17 @@ export function StockBulkButtons({ items, activeConnectionId, onImported, }) {
|
|
|
107
108
|
if (open)
|
|
108
109
|
void loadPastRuns();
|
|
109
110
|
}, [open, loadPastRuns]);
|
|
111
|
+
// Venster open houden tijdens verwerken.
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!busy || busy === "lezen")
|
|
114
|
+
return;
|
|
115
|
+
const h = (e) => {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
e.returnValue = "";
|
|
118
|
+
};
|
|
119
|
+
window.addEventListener("beforeunload", h);
|
|
120
|
+
return () => window.removeEventListener("beforeunload", h);
|
|
121
|
+
}, [busy]);
|
|
110
122
|
const byCode = useMemo(() => {
|
|
111
123
|
const map = new Map();
|
|
112
124
|
for (const it of items) {
|
|
@@ -116,7 +128,6 @@ export function StockBulkButtons({ items, activeConnectionId, onImported, }) {
|
|
|
116
128
|
}
|
|
117
129
|
return map;
|
|
118
130
|
}, [items]);
|
|
119
|
-
/** Published Floriday items only (voor auto-koppeling van nieuwe artikelen). */
|
|
120
131
|
const publishedByCode = useMemo(() => {
|
|
121
132
|
const map = new Map();
|
|
122
133
|
for (const it of items) {
|
|
@@ -170,158 +181,84 @@ export function StockBulkButtons({ items, activeConnectionId, onImported, }) {
|
|
|
170
181
|
});
|
|
171
182
|
const headers = [...COLUMNS, ...templateFields.map((f) => f.field_label)];
|
|
172
183
|
const ws = XLSX.utils.json_to_sheet(rows, { header: headers });
|
|
173
|
-
ws["!cols"] = headers.map((c) => ({
|
|
174
|
-
wch: c === "Artikelnaam" ? 40 : c === "Artikelcode" ? 18 : 20,
|
|
175
|
-
}));
|
|
184
|
+
ws["!cols"] = headers.map((c) => ({ wch: c === "Artikelnaam" ? 40 : c === "Artikelcode" ? 18 : 20 }));
|
|
176
185
|
const wb = XLSX.utils.book_new();
|
|
177
186
|
XLSX.utils.book_append_sheet(wb, ws, "Voorraad");
|
|
178
|
-
|
|
179
|
-
XLSX.writeFile(wb, `voorraad-template-${stamp}.xlsx`);
|
|
187
|
+
XLSX.writeFile(wb, `voorraad-template-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
180
188
|
if (generatedCount > 0) {
|
|
181
189
|
toast.success(`${generatedCount} artikel(en) kregen automatisch een nieuwe artikelcode.`);
|
|
182
190
|
await onImported();
|
|
183
191
|
}
|
|
184
192
|
};
|
|
185
|
-
|
|
186
|
-
if (!result?.errors.length)
|
|
187
|
-
return;
|
|
188
|
-
const rows = result.errors.map((e) => ({
|
|
189
|
-
Regel: e.row,
|
|
190
|
-
Artikelcode: e.articleCode,
|
|
191
|
-
Artikelnaam: e.articleName,
|
|
192
|
-
Fout: e.reden,
|
|
193
|
-
}));
|
|
194
|
-
const ws = XLSX.utils.json_to_sheet(rows);
|
|
195
|
-
const wb = XLSX.utils.book_new();
|
|
196
|
-
XLSX.utils.book_append_sheet(wb, ws, "Fouten");
|
|
197
|
-
XLSX.writeFile(wb, `voorraad-import-fouten-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
198
|
-
};
|
|
193
|
+
/** Stap 1: bestand lezen en per rij controleren (nog niets opgeslagen). */
|
|
199
194
|
const handleFile = async (file) => {
|
|
200
195
|
if (!activeConnectionId) {
|
|
201
196
|
toast.error("Geen actieve Floriday-connectie");
|
|
202
197
|
return;
|
|
203
198
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const errors = [];
|
|
208
|
-
const mutations = [];
|
|
209
|
-
let updated = 0;
|
|
210
|
-
let created = 0;
|
|
211
|
-
let linked = 0;
|
|
212
|
-
let published = 0;
|
|
213
|
-
let waiting = 0;
|
|
199
|
+
setBusy("lezen");
|
|
200
|
+
setBanner(null);
|
|
201
|
+
setPrepared(null);
|
|
214
202
|
try {
|
|
215
203
|
const buf = await file.arrayBuffer();
|
|
204
|
+
const fileHash = await sha256(buf);
|
|
216
205
|
const wb = XLSX.read(buf, { type: "array" });
|
|
217
206
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
|
218
|
-
const rows = XLSX.utils.sheet_to_json(sheet, {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// "Nieuw sinds laatste import" — timestamp opslaan
|
|
226
|
-
try {
|
|
227
|
-
localStorage.setItem(`voorraad_last_import_${activeConnectionId}`, new Date().toISOString());
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
/* ignore quota */
|
|
231
|
-
}
|
|
232
|
-
const stockBatch = [];
|
|
233
|
-
for (let idx = 0; idx < rows.length; idx++) {
|
|
234
|
-
const row = rows[idx];
|
|
235
|
-
const excelRow = idx + 2;
|
|
207
|
+
const rows = XLSX.utils.sheet_to_json(sheet, { defval: "" });
|
|
208
|
+
const out = [];
|
|
209
|
+
let existing = 0;
|
|
210
|
+
let fresh = 0;
|
|
211
|
+
let reservedIgnored = 0;
|
|
212
|
+
rows.forEach((row, idx) => {
|
|
213
|
+
const rowNo = idx + 2;
|
|
236
214
|
const code = String(row["Artikelcode"] ?? "").trim();
|
|
237
215
|
const name = String(row["Artikelnaam"] ?? "").trim();
|
|
216
|
+
const base = {
|
|
217
|
+
row_no: rowNo,
|
|
218
|
+
article_code: code,
|
|
219
|
+
article_name: name,
|
|
220
|
+
product_id: null,
|
|
221
|
+
is_new: false,
|
|
222
|
+
new_payload: null,
|
|
223
|
+
custom_values: null,
|
|
224
|
+
physical_in: null,
|
|
225
|
+
minimum_in: null,
|
|
226
|
+
incoming_in: null,
|
|
227
|
+
error: null,
|
|
228
|
+
};
|
|
238
229
|
if (!code) {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
articleCode: "",
|
|
242
|
-
articleName: name,
|
|
243
|
-
reden: "Artikelcode ontbreekt",
|
|
244
|
-
});
|
|
245
|
-
continue;
|
|
230
|
+
out.push({ ...base, error: "Artikelcode ontbreekt" });
|
|
231
|
+
return;
|
|
246
232
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
["
|
|
251
|
-
["
|
|
252
|
-
["
|
|
233
|
+
if (String(row["Gereserveerde voorraad"] ?? "").trim() !== "")
|
|
234
|
+
reservedIgnored++;
|
|
235
|
+
const fields = [
|
|
236
|
+
["physical_in", "Liggende voorraad"],
|
|
237
|
+
["minimum_in", "Minimum voorraad"],
|
|
238
|
+
["incoming_in", "Inkomende voorraad"],
|
|
253
239
|
];
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
stockError = `${col}: ${parsed.error}`;
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
if (parsed.value !== null && !allowNegative && parsed.value < 0) {
|
|
263
|
-
stockError = `${col}: negatieve waarde niet toegestaan`;
|
|
264
|
-
break;
|
|
240
|
+
for (const [key, col] of fields) {
|
|
241
|
+
const p = parseNumber(row[col]);
|
|
242
|
+
if (p.error) {
|
|
243
|
+
out.push({ ...base, error: `${col}: ${p.error}` });
|
|
244
|
+
return;
|
|
265
245
|
}
|
|
266
|
-
|
|
267
|
-
}
|
|
268
|
-
if (stockError) {
|
|
269
|
-
errors.push({
|
|
270
|
-
row: excelRow,
|
|
271
|
-
articleCode: code,
|
|
272
|
-
articleName: name,
|
|
273
|
-
reden: stockError,
|
|
274
|
-
});
|
|
275
|
-
continue;
|
|
246
|
+
base[key] = p.value;
|
|
276
247
|
}
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
};
|
|
286
|
-
const connId = existing.raw?.connection_id ?? activeConnectionId;
|
|
287
|
-
stockBatch.push({
|
|
288
|
-
user_id: userId,
|
|
289
|
-
connection_id: connId,
|
|
290
|
-
trade_item_key: toKey(existing),
|
|
291
|
-
...applied,
|
|
292
|
-
});
|
|
293
|
-
mutations.push({
|
|
294
|
-
articleCode: code,
|
|
295
|
-
articleName: existing.name,
|
|
296
|
-
oldPhysical: existing.quantity,
|
|
297
|
-
newPhysical: applied.physical_stock,
|
|
298
|
-
oldReserved: existing.reservedQuantity,
|
|
299
|
-
newReserved: applied.reserved_stock,
|
|
300
|
-
});
|
|
301
|
-
// Eigen voorraadvelden uit de template terugschrijven
|
|
302
|
-
const targetProductId = existing.productId ?? existing.localId ?? null;
|
|
303
|
-
if (targetProductId && customFields.length > 0) {
|
|
304
|
-
const patch = readCustomValues(row);
|
|
305
|
-
if (Object.keys(patch).length > 0) {
|
|
306
|
-
try {
|
|
307
|
-
await saveProductCustomValues(targetProductId, patch);
|
|
308
|
-
}
|
|
309
|
-
catch {
|
|
310
|
-
/* eigen velden zijn niet blokkerend voor de voorraadimport */
|
|
311
|
-
}
|
|
312
|
-
}
|
|
248
|
+
const custom = readCustomValues(row);
|
|
249
|
+
base.custom_values = Object.keys(custom).length ? custom : null;
|
|
250
|
+
const ex = byCode.get(code);
|
|
251
|
+
if (ex) {
|
|
252
|
+
const pid = ex.productId ?? ex.localId ?? null;
|
|
253
|
+
if (!pid) {
|
|
254
|
+
out.push({ ...base, error: "Artikel heeft nog geen lokale rij; open het eerst in de voorraadlijst" });
|
|
255
|
+
return;
|
|
313
256
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
257
|
+
existing++;
|
|
258
|
+
out.push({ ...base, product_id: pid });
|
|
259
|
+
return;
|
|
317
260
|
}
|
|
318
|
-
// === Nieuw artikel aanmaken ===
|
|
319
261
|
const vbnCode = String(row["VBN-code"] ?? "").trim();
|
|
320
|
-
const botanical = String(row["Botanische naam"] ?? "").trim();
|
|
321
|
-
const country = String(row["Land van oorsprong"] ?? "")
|
|
322
|
-
.trim()
|
|
323
|
-
.toUpperCase();
|
|
324
|
-
const gtin = String(row["GTIN"] ?? "").trim();
|
|
325
262
|
const data = {
|
|
326
263
|
...emptyWizardData,
|
|
327
264
|
articleCode: code,
|
|
@@ -329,176 +266,271 @@ export function StockBulkButtons({ items, activeConnectionId, onImported, }) {
|
|
|
329
266
|
vbnCode,
|
|
330
267
|
vbnName: "",
|
|
331
268
|
productGroup: productGroupForVbn(vbnCode),
|
|
332
|
-
botanicalName:
|
|
333
|
-
countryOfOrigin:
|
|
334
|
-
gtin,
|
|
269
|
+
botanicalName: String(row["Botanische naam"] ?? "").trim(),
|
|
270
|
+
countryOfOrigin: String(row["Land van oorsprong"] ?? "").trim().toUpperCase(),
|
|
271
|
+
gtin: String(row["GTIN"] ?? "").trim(),
|
|
335
272
|
};
|
|
336
|
-
// Zoek in cache van gepubliceerde Floriday-items
|
|
337
273
|
const matches = publishedByCode.get(code) ?? [];
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
stockValues.minimum_stock !== null ||
|
|
406
|
-
stockValues.reserved_stock !== null ||
|
|
407
|
-
stockValues.incoming_stock !== null;
|
|
408
|
-
if (anyStock) {
|
|
409
|
-
stockBatch.push({
|
|
410
|
-
user_id: userId,
|
|
411
|
-
connection_id: activeConnectionId,
|
|
412
|
-
trade_item_key: `product:${draftId}`,
|
|
413
|
-
physical_stock: stockValues.physical_stock ?? 0,
|
|
414
|
-
minimum_stock: stockValues.minimum_stock ?? 0,
|
|
415
|
-
reserved_stock: stockValues.reserved_stock ?? 0,
|
|
416
|
-
incoming_stock: stockValues.incoming_stock ?? 0,
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
catch (e) {
|
|
421
|
-
errors.push({
|
|
422
|
-
row: excelRow,
|
|
423
|
-
articleCode: code,
|
|
424
|
-
articleName: name,
|
|
425
|
-
reden: `Aanmaken mislukt: ${e?.message ?? e}`,
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
setProgress(Math.round(((idx + 1) / rows.length) * 100));
|
|
429
|
-
}
|
|
430
|
-
// Stock batch upserten
|
|
431
|
-
const CHUNK = 500;
|
|
432
|
-
for (let i = 0; i < stockBatch.length; i += CHUNK) {
|
|
433
|
-
const slice = stockBatch.slice(i, i + CHUNK);
|
|
434
|
-
const { error } = await supabase
|
|
435
|
-
.from("trade_item_stock")
|
|
436
|
-
.upsert(slice, { onConflict: "connection_id,trade_item_key" });
|
|
437
|
-
if (error) {
|
|
438
|
-
errors.push({
|
|
439
|
-
row: 0,
|
|
440
|
-
articleCode: "(voorraad-batch)",
|
|
441
|
-
articleName: "",
|
|
442
|
-
reden: `Database-fout: ${error.message}`,
|
|
274
|
+
const validation = validateDraftForPublish(data);
|
|
275
|
+
fresh++;
|
|
276
|
+
out.push({
|
|
277
|
+
...base,
|
|
278
|
+
is_new: true,
|
|
279
|
+
new_payload: {
|
|
280
|
+
payload: data,
|
|
281
|
+
gtin: data.gtin || null,
|
|
282
|
+
floriday_id: matches.length === 1 ? (matches[0].floridayId ?? matches[0].id) : null,
|
|
283
|
+
import_status: validation.ok ? "klaar_om_te_publiceren" : "wacht_op_gegevens",
|
|
284
|
+
missing: validation.missing ?? [],
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
setPrepared({
|
|
289
|
+
fileName: file.name,
|
|
290
|
+
fileHash,
|
|
291
|
+
rows: out,
|
|
292
|
+
existing,
|
|
293
|
+
fresh,
|
|
294
|
+
errors: out.filter((r) => r.error),
|
|
295
|
+
reservedIgnored,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
setBanner({ kind: "error", text: `Bestand lezen mislukt: ${e.message}` });
|
|
300
|
+
}
|
|
301
|
+
finally {
|
|
302
|
+
setBusy(null);
|
|
303
|
+
if (fileRef.current)
|
|
304
|
+
fileRef.current.value = "";
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
/** Verwerkt alle porties die nog wachten, één voor één. */
|
|
308
|
+
const runChunks = useCallback(async (runId) => {
|
|
309
|
+
setBusy("importeren");
|
|
310
|
+
setBanner(null);
|
|
311
|
+
try {
|
|
312
|
+
const { data: run } = await supabase
|
|
313
|
+
.from("stock_import_runs")
|
|
314
|
+
.select("total_chunks,total_rows")
|
|
315
|
+
.eq("id", runId)
|
|
316
|
+
.maybeSingle();
|
|
317
|
+
const { data: chunks, error } = await supabase
|
|
318
|
+
.from("stock_import_chunks")
|
|
319
|
+
.select("chunk_no,status")
|
|
320
|
+
.eq("run_id", runId)
|
|
321
|
+
.order("chunk_no");
|
|
322
|
+
if (error)
|
|
323
|
+
throw new Error(error.message);
|
|
324
|
+
const total = run?.total_chunks ?? chunks?.length ?? 0;
|
|
325
|
+
const totalRows = run?.total_rows ?? 0;
|
|
326
|
+
const chunkSize = total > 0 ? Math.ceil(totalRows / total) : 0;
|
|
327
|
+
let done = (chunks ?? []).filter((c) => c.status === "klaar").length;
|
|
328
|
+
setProgress({ done, total, rows: Math.min(done * chunkSize, totalRows), totalRows });
|
|
329
|
+
for (const c of (chunks ?? [])) {
|
|
330
|
+
if (c.status !== "wacht")
|
|
331
|
+
continue;
|
|
332
|
+
const { data, error: e } = await rpc("stock_import_process_chunk", { p_run_id: runId, p_chunk_no: c.chunk_no });
|
|
333
|
+
if (e || !data?.ok) {
|
|
334
|
+
await rpc("stock_import_stop", { p_run_id: runId });
|
|
335
|
+
const rowsDone = Math.min(done * chunkSize, totalRows);
|
|
336
|
+
setBanner({
|
|
337
|
+
kind: "error",
|
|
338
|
+
runId,
|
|
339
|
+
actions: ["resume", "undo"],
|
|
340
|
+
text: `Gestopt bij portie ${c.chunk_no} van ${total}. ${rowsDone} rijen zijn al bijgewerkt, elke portie is volledig; kies Verdergaan of Ongedaan maken.${e?.message ? ` (Melding: ${e.message})` : ""}`,
|
|
443
341
|
});
|
|
342
|
+
return;
|
|
444
343
|
}
|
|
344
|
+
done = data.done_chunks ?? done + 1;
|
|
345
|
+
setProgress({ done, total, rows: Math.min(done * chunkSize, totalRows), totalRows });
|
|
445
346
|
}
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
347
|
+
const { data: fin } = await supabase
|
|
348
|
+
.from("stock_import_runs")
|
|
349
|
+
.select("updated_rows,created_rows,skipped_rows")
|
|
350
|
+
.eq("id", runId)
|
|
351
|
+
.maybeSingle();
|
|
352
|
+
const f = (fin ?? {});
|
|
353
|
+
const { count: zeroCount } = await supabase
|
|
354
|
+
.from("stock_import_rows")
|
|
355
|
+
.select("id", { count: "exact", head: true })
|
|
356
|
+
.eq("run_id", runId)
|
|
357
|
+
.eq("status", "verwerkt")
|
|
358
|
+
.eq("physical_after", 0)
|
|
359
|
+
.gt("physical_before", 0);
|
|
360
|
+
setBanner({
|
|
361
|
+
kind: "success",
|
|
362
|
+
runId,
|
|
363
|
+
actions: ["undo"],
|
|
364
|
+
text: `Voltooid: ${f.updated_rows ?? 0} bijgewerkt, ${f.created_rows ?? 0} nieuw (klaar om te publiceren of wacht op gegevens), ${f.skipped_rows ?? 0} overgeslagen. ${zeroCount ?? 0} artikelen op 0 gezet.`,
|
|
365
|
+
});
|
|
366
|
+
setPrepared(null);
|
|
367
|
+
await onImported();
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
await rpc("stock_import_stop", { p_run_id: runId });
|
|
371
|
+
setBanner({
|
|
372
|
+
kind: "error",
|
|
373
|
+
runId,
|
|
374
|
+
actions: ["resume", "undo"],
|
|
375
|
+
text: `De import is gestopt: ${e.message}. Elke verwerkte portie is volledig; kies Verdergaan of Ongedaan maken.`,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
finally {
|
|
379
|
+
setBusy(null);
|
|
380
|
+
setProgress(null);
|
|
381
|
+
void loadPastRuns();
|
|
382
|
+
}
|
|
383
|
+
}, [loadPastRuns, onImported]);
|
|
384
|
+
/** Stap 2: run aanmaken en porties verwerken. */
|
|
385
|
+
const startImport = async (force = false) => {
|
|
386
|
+
if (!prepared)
|
|
387
|
+
return;
|
|
388
|
+
setBusy("importeren");
|
|
389
|
+
setBanner(null);
|
|
390
|
+
const { data, error } = await rpc("stock_import_create", {
|
|
391
|
+
p_connection_id: activeConnectionId,
|
|
392
|
+
p_filename: prepared.fileName,
|
|
393
|
+
p_file_hash: prepared.fileHash,
|
|
394
|
+
p_rows: prepared.rows,
|
|
395
|
+
p_force: force,
|
|
396
|
+
});
|
|
397
|
+
if (error) {
|
|
398
|
+
setBusy(null);
|
|
399
|
+
setBanner({ kind: "error", text: `Import starten mislukt: ${error.message}` });
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (!data?.ok) {
|
|
403
|
+
setBusy(null);
|
|
404
|
+
if (data?.code === "IMPORT_DUPLICATE") {
|
|
405
|
+
setBanner({ kind: "warning", text: data.message, actions: ["force"] });
|
|
473
406
|
}
|
|
474
|
-
|
|
475
|
-
|
|
407
|
+
else if (data?.code === "IMPORT_ACTIVE") {
|
|
408
|
+
setBanner({ kind: "warning", text: data.message, runId: data.run_id, actions: data.run_id ? ["resume", "undo"] : [] });
|
|
476
409
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
await onImported();
|
|
410
|
+
else {
|
|
411
|
+
setBanner({ kind: "error", text: data?.message ?? "Import kon niet starten." });
|
|
480
412
|
}
|
|
481
|
-
|
|
482
|
-
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
await runChunks(data.run_id);
|
|
416
|
+
};
|
|
417
|
+
/** Ongedaan maken per portie; alleen velden die sinds de import niet gewijzigd zijn. */
|
|
418
|
+
const undoRun = async (runId) => {
|
|
419
|
+
if (!window.confirm("Deze import ongedaan maken? Velden die sindsdien door iets anders zijn gewijzigd, blijven staan en worden gemeld."))
|
|
420
|
+
return;
|
|
421
|
+
setBusy("ongedaan");
|
|
422
|
+
setBanner(null);
|
|
423
|
+
try {
|
|
424
|
+
const { data: chunks, error } = await supabase
|
|
425
|
+
.from("stock_import_chunks")
|
|
426
|
+
.select("chunk_no,status")
|
|
427
|
+
.eq("run_id", runId)
|
|
428
|
+
.eq("status", "klaar")
|
|
429
|
+
.order("chunk_no");
|
|
430
|
+
if (error)
|
|
431
|
+
throw new Error(error.message);
|
|
432
|
+
let back = 0;
|
|
433
|
+
let skipped = 0;
|
|
434
|
+
const total = chunks?.length ?? 0;
|
|
435
|
+
let i = 0;
|
|
436
|
+
for (const c of (chunks ?? [])) {
|
|
437
|
+
const { data, error: e } = await rpc("stock_import_undo_chunk", { p_run_id: runId, p_chunk_no: c.chunk_no });
|
|
438
|
+
if (e)
|
|
439
|
+
throw new Error(e.message);
|
|
440
|
+
back += data?.undone ?? 0;
|
|
441
|
+
skipped += data?.skipped ?? 0;
|
|
442
|
+
i++;
|
|
443
|
+
setProgress({ done: i, total, rows: back + skipped, totalRows: 0 });
|
|
483
444
|
}
|
|
445
|
+
const { count: backToZero } = await supabase
|
|
446
|
+
.from("stock_import_rows")
|
|
447
|
+
.select("id", { count: "exact", head: true })
|
|
448
|
+
.eq("run_id", runId)
|
|
449
|
+
.eq("status", "teruggezet")
|
|
450
|
+
.eq("physical_before", 0)
|
|
451
|
+
.gt("physical_after", 0);
|
|
452
|
+
setBanner({
|
|
453
|
+
kind: skipped > 0 ? "warning" : "success",
|
|
454
|
+
runId,
|
|
455
|
+
text: `Ongedaan: ${back} rijen teruggezet${skipped > 0 ? `, ${skipped} overgeslagen omdat de voorraad sindsdien is gewijzigd` : ""}. ${backToZero ?? 0} artikelen terug naar 0.`,
|
|
456
|
+
});
|
|
457
|
+
await onImported();
|
|
484
458
|
}
|
|
485
459
|
catch (e) {
|
|
486
|
-
|
|
460
|
+
setBanner({
|
|
461
|
+
kind: "error",
|
|
462
|
+
runId,
|
|
463
|
+
actions: ["undo"],
|
|
464
|
+
text: `Ongedaan maken gestopt: ${e.message}. Kies opnieuw Ongedaan maken om verder te gaan.`,
|
|
465
|
+
});
|
|
487
466
|
}
|
|
488
467
|
finally {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
468
|
+
setBusy(null);
|
|
469
|
+
setProgress(null);
|
|
470
|
+
void loadPastRuns();
|
|
492
471
|
}
|
|
493
472
|
};
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
473
|
+
const downloadRowReport = async (runId) => {
|
|
474
|
+
const all = [];
|
|
475
|
+
for (let from = 0;; from += 1000) {
|
|
476
|
+
const { data, error } = await supabase
|
|
477
|
+
.from("stock_import_rows")
|
|
478
|
+
.select("row_no,article_code,article_name,status,error,note")
|
|
479
|
+
.eq("run_id", runId)
|
|
480
|
+
.in("status", ["fout", "niet_teruggezet"])
|
|
481
|
+
.order("row_no")
|
|
482
|
+
.range(from, from + 999);
|
|
483
|
+
if (error) {
|
|
484
|
+
toast.error(error.message);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
all.push(...(data ?? []));
|
|
488
|
+
if (!data || data.length < 1000)
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
if (all.length === 0) {
|
|
492
|
+
toast.success("Geen fouten of overgeslagen rijen in deze import.");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const ws = XLSX.utils.json_to_sheet(all.map((r) => ({
|
|
496
|
+
Regel: r.row_no,
|
|
497
|
+
Artikelcode: r.article_code,
|
|
498
|
+
Artikelnaam: r.article_name,
|
|
499
|
+
Status: r.status === "fout" ? "Overgeslagen" : "Niet teruggezet",
|
|
500
|
+
Reden: r.error ?? r.note ?? "",
|
|
501
|
+
})));
|
|
502
|
+
const wb = XLSX.utils.book_new();
|
|
503
|
+
XLSX.utils.book_append_sheet(wb, ws, "Rijen");
|
|
504
|
+
XLSX.writeFile(wb, `voorraad-import-rijen-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
505
|
+
};
|
|
506
|
+
const downloadPreparedErrors = () => {
|
|
507
|
+
if (!prepared?.errors.length)
|
|
508
|
+
return;
|
|
509
|
+
const ws = XLSX.utils.json_to_sheet(prepared.errors.map((e) => ({ Regel: e.row_no, Artikelcode: e.article_code, Artikelnaam: e.article_name, Fout: e.error })));
|
|
510
|
+
const wb = XLSX.utils.book_new();
|
|
511
|
+
XLSX.utils.book_append_sheet(wb, ws, "Fouten");
|
|
512
|
+
XLSX.writeFile(wb, `voorraad-import-fouten-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
|
513
|
+
};
|
|
514
|
+
const bannerClass = banner?.kind === "error"
|
|
515
|
+
? "border-destructive/50 bg-destructive/10 text-destructive"
|
|
516
|
+
: banner?.kind === "warning"
|
|
517
|
+
? "border-warning/50 bg-warning/10"
|
|
518
|
+
: banner?.kind === "success"
|
|
519
|
+
? "border-primary/40 bg-primary/5"
|
|
520
|
+
: "bg-muted/30";
|
|
521
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Button, { variant: "outline", onClick: downloadTemplate, disabled: items.length === 0, children: [_jsx(FileDown, { className: "mr-2 h-4 w-4" }), "Template downloaden"] }), _jsxs(Button, { variant: "outline", onClick: () => setOpen(true), children: [_jsx(Upload, { className: "mr-2 h-4 w-4" }), "Voorraad importeren"] }), _jsx(Dialog, { open: open, onOpenChange: (o) => !busy && setOpen(o), children: _jsxs(DialogContent, { className: "sm:max-w-lg", children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: "Voorraad importeren" }), _jsxs(DialogDescription, { children: ["Upload een Excel-bestand (op basis van de template). Bestaande artikelen worden gematcht op", " ", _jsx("strong", { children: "Artikelcode" }), "; liggende, minimum- en inkomende voorraad worden bijgewerkt. Gereserveerde voorraad wordt nooit ge\u00EFmporteerd. Nieuwe artikelcodes worden als concept aangemaakt en niet naar Floriday gepubliceerd."] })] }), _jsxs("div", { className: "space-y-4", children: [_jsx("input", { ref: fileRef, type: "file", accept: ".xlsx,.xls", className: "block w-full text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50", disabled: !!busy, onChange: (e) => {
|
|
497
522
|
const f = e.target.files?.[0];
|
|
498
523
|
if (f)
|
|
499
524
|
void handleFile(f);
|
|
500
|
-
} }),
|
|
525
|
+
} }), busy === "lezen" && (_jsxs("p", { className: "text-xs text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-3 w-3 animate-spin" }), " Bestand lezen en controleren\u2026"] })), prepared && !busy && (_jsxs("div", { className: "rounded-md border bg-muted/30 p-3 text-sm space-y-2", children: [_jsxs("div", { className: "font-medium", children: [prepared.rows.length, " rijen gelezen: ", prepared.existing, " bestaande artikelen, ", prepared.fresh, " nieuwe,", " ", prepared.errors.length, " met een fout"] }), prepared.reservedIgnored > 0 && (_jsxs("p", { className: "text-xs text-muted-foreground", children: ["De kolom \"Gereserveerde voorraad\" is bij ", prepared.reservedIgnored, " rijen ingevuld en wordt genegeerd; reserveringen beheert de logistiek."] })), prepared.errors.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "max-h-32 overflow-y-auto rounded border bg-background p-2 text-xs space-y-1", children: [prepared.errors.slice(0, 20).map((e) => (_jsxs("div", { className: "text-destructive", children: ["Rij ", e.row_no, ": ", e.error] }, e.row_no))), prepared.errors.length > 20 && (_jsxs("div", { className: "text-muted-foreground", children: ["\u2026 en ", prepared.errors.length - 20, " meer."] }))] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: downloadPreparedErrors, children: [_jsx(Download, { className: "mr-2 h-4 w-4" }), " Fouten downloaden"] })] })), _jsxs("div", { className: "flex gap-2 pt-1", children: [_jsx(Button, { size: "sm", onClick: () => void startImport(false), disabled: prepared.rows.length === prepared.errors.length, children: "Importeren" }), _jsx(Button, { size: "sm", variant: "ghost", onClick: () => setPrepared(null), children: "Annuleren" })] })] })), progress && (busy === "importeren" || busy === "ongedaan") && (_jsxs("div", { className: "space-y-1", children: [_jsx("div", { className: "h-2 w-full overflow-hidden rounded bg-muted", children: _jsx("div", { className: "h-full bg-primary transition-all", style: { width: `${progress.total ? Math.round((progress.done / progress.total) * 100) : 0}%` } }) }), _jsxs("p", { className: "text-xs text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-3 w-3 animate-spin" }), busy === "importeren"
|
|
526
|
+
? `Bezig: ${progress.rows} van ${progress.totalRows} rijen (portie ${progress.done} van ${progress.total}). Houd dit venster open; sluit je het, dan ga je later verder met Verdergaan.`
|
|
527
|
+
: `Ongedaan maken: portie ${progress.done} van ${progress.total}. Houd dit venster open.`] })] })), banner && (_jsxs("div", { className: `rounded-md border p-3 text-sm space-y-2 ${bannerClass}`, children: [_jsx("p", { children: banner.text }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [banner.actions?.includes("force") && (_jsx(Button, { size: "sm", variant: "outline", onClick: () => void startImport(true), disabled: !!busy, children: "Toch importeren" })), banner.runId && banner.actions?.includes("resume") && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void runChunks(banner.runId), disabled: !!busy, children: [_jsx(Play, { className: "mr-2 h-4 w-4" }), " Verdergaan"] })), banner.runId && banner.actions?.includes("undo") && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void undoRun(banner.runId), disabled: !!busy, children: [_jsx(Undo2, { className: "mr-2 h-4 w-4" }), " Ongedaan maken"] })), banner.runId && (_jsxs(Button, { size: "sm", variant: "ghost", onClick: () => void downloadRowReport(banner.runId), children: [_jsx(Download, { className: "mr-2 h-4 w-4" }), " Fouten downloaden"] }))] })] })), !busy && pastRuns.length > 0 && (_jsxs("div", { className: "rounded-md border bg-background p-3 text-sm space-y-2", children: [_jsx("div", { className: "font-medium", children: "Importgeschiedenis" }), _jsx("ul", { className: "text-xs space-y-1 max-h-56 overflow-y-auto", children: pastRuns.map((r) => {
|
|
528
|
+
const active = ACTIVE.includes(r.status);
|
|
529
|
+
const canUndo = (r.status === "voltooid" || active) && r.done_chunks > 0;
|
|
530
|
+
return (_jsxs("li", { className: "border-b last:border-0 py-1 space-y-1", children: [_jsxs("div", { className: "flex justify-between gap-2", children: [_jsxs("span", { className: "truncate", children: [_jsx("span", { className: "text-muted-foreground", children: new Date(r.created_at).toLocaleString("nl-NL") }), " ", "\u2014 ", r.filename ?? "(zonder naam)"] }), _jsxs("span", { className: "shrink-0 tabular-nums text-muted-foreground", children: [STATUS_LABEL[active && r.status !== "gestopt" ? "gestopt" : r.status] ?? r.status, r.total_chunks > 0 && active ? ` · ${r.done_chunks}/${r.total_chunks}` : "", " \u00B7 ", r.updated_rows, "\u21BB \u00B7", " ", r.created_rows, "+ \u00B7 ", r.skipped_rows, "\u2715", r.undo_skipped_rows > 0 ? ` · ${r.undo_skipped_rows} niet teruggezet` : ""] })] }), (active || canUndo) && (_jsxs("div", { className: "flex gap-2", children: [active && (_jsxs(Button, { size: "sm", variant: "outline", onClick: () => void runChunks(r.id), children: [_jsx(Play, { className: "mr-1 h-3 w-3" }), " Verdergaan"] })), canUndo && (_jsxs(Button, { size: "sm", variant: "ghost", onClick: () => void undoRun(r.id), children: [_jsx(Undo2, { className: "mr-1 h-3 w-3" }), " Ongedaan maken"] }))] }))] }, r.id));
|
|
531
|
+
}) })] }))] }), _jsx(DialogFooter, { children: _jsx(Button, { variant: "ghost", onClick: () => {
|
|
501
532
|
setOpen(false);
|
|
502
|
-
|
|
503
|
-
|
|
533
|
+
setPrepared(null);
|
|
534
|
+
setBanner(null);
|
|
535
|
+
}, disabled: !!busy, children: "Sluiten" }) })] }) })] }));
|
|
504
536
|
}
|