@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.
@@ -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, Checkbox, Label, toast, } from "@flowselections/core";
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
- import { saveProductCustomValues } from "../../lib/voorraad-custom-fields";
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
- * Bepaalt de canonieke sleutel in `trade_item_stock`: de kale Floriday-UUID
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 [importing, setImporting] = useState(false);
64
- const [progress, setProgress] = useState(0);
65
- const allowNegative = true;
66
- const [autoPublish, setAutoPublish] = useState(false);
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,created_at,errors,mutations")
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
- const stamp = new Date().toISOString().slice(0, 10);
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
- const downloadErrors = () => {
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
- setImporting(true);
205
- setProgress(0);
206
- setResult(null);
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
- defval: "",
220
- });
221
- const userRes = await supabase.auth.getUser();
222
- const userId = userRes.data.user?.id;
223
- if (!userId)
224
- throw new Error("Niet ingelogd");
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
- errors.push({
240
- row: excelRow,
241
- articleCode: "",
242
- articleName: name,
243
- reden: "Artikelcode ontbreekt",
244
- });
245
- continue;
230
+ out.push({ ...base, error: "Artikelcode ontbreekt" });
231
+ return;
246
232
  }
247
- // Voorraadvelden parsen
248
- const stockFields = [
249
- ["physical_stock", "Liggende voorraad"],
250
- ["minimum_stock", "Minimum voorraad"],
251
- ["reserved_stock", "Gereserveerde voorraad"],
252
- ["incoming_stock", "Inkomende voorraad"],
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 stockValues = {};
255
- let stockError = null;
256
- for (const [key, col] of stockFields) {
257
- const parsed = parseNumber(row[col]);
258
- if (parsed.error) {
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
- stockValues[key] = parsed.value;
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 existing = byCode.get(code);
278
- // === Bestaand artikel: alleen stock bijwerken ===
279
- if (existing) {
280
- const applied = {
281
- physical_stock: stockValues.physical_stock ?? existing.quantity,
282
- minimum_stock: stockValues.minimum_stock ?? existing.minQuantity,
283
- reserved_stock: stockValues.reserved_stock ?? existing.reservedQuantity,
284
- incoming_stock: stockValues.incoming_stock ?? existing.incomingQuantity,
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
- updated++;
315
- setProgress(Math.round(((idx + 1) / rows.length) * 100));
316
- continue;
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: botanical,
333
- countryOfOrigin: country,
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
- let linkStatus = "unlinked";
339
- let floridayId = null;
340
- let publishError = null;
341
- if (matches.length === 1) {
342
- linkStatus = "linked";
343
- floridayId = matches[0].id;
344
- linked++;
345
- }
346
- else if (matches.length > 1) {
347
- linkStatus = "ambiguous_match";
348
- }
349
- else {
350
- // Geen match — evalueer validatie
351
- const validation = validateDraftForPublish(data);
352
- if (validation.ok && autoPublish) {
353
- // Poging tot publiceren
354
- try {
355
- const { buildAddTradeItemPayload } = await import("../../lib/floriday-payload");
356
- const payload = buildAddTradeItemPayload(data);
357
- const res = await floriday.createTradeItem(activeConnectionId, payload);
358
- floridayId = res?.tradeItemId ?? null;
359
- linkStatus = "published";
360
- published++;
361
- }
362
- catch (e) {
363
- publishError = e?.message ?? String(e);
364
- linkStatus = "publish_failed";
365
- }
366
- }
367
- else if (validation.ok) {
368
- linkStatus = "ready_to_publish";
369
- }
370
- else {
371
- linkStatus = "waiting_for_data";
372
- waiting++;
373
- }
374
- }
375
- // Maak draft aan
376
- try {
377
- const validation = validateDraftForPublish(data);
378
- const draftId = await upsertLocalTradeItem({
379
- connectionId: activeConnectionId,
380
- data,
381
- meta: {
382
- floridayLinkStatus: linkStatus,
383
- floridayId,
384
- floridayLinkError: publishError,
385
- missingRequiredFields: validation.missing,
386
- lastFloridaySyncAt: new Date().toISOString(),
387
- source: "excel_import",
388
- },
389
- });
390
- created++;
391
- // Eigen voorraadvelden ook op nieuw aangemaakte producten zetten
392
- if (customFields.length > 0) {
393
- const patch = readCustomValues(row);
394
- if (Object.keys(patch).length > 0) {
395
- try {
396
- await saveProductCustomValues(draftId, patch);
397
- }
398
- catch {
399
- /* niet blokkerend */
400
- }
401
- }
402
- }
403
- // Stock koppelen aan het nieuwe product (draftId is nu de product-uuid)
404
- const anyStock = stockValues.physical_stock !== null ||
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 finalResult = {
447
- updated,
448
- created,
449
- linked,
450
- published,
451
- waiting,
452
- errors,
453
- mutations,
454
- };
455
- setResult(finalResult);
456
- // Persisteer het importverslag zodat het achteraf herbekeken kan worden.
457
- try {
458
- const { error: logErr } = await supabase.from("stock_import_runs").insert({
459
- user_id: userId,
460
- connection_id: activeConnectionId,
461
- filename: file.name,
462
- total_rows: rows.length,
463
- updated_rows: updated,
464
- created_rows: created,
465
- skipped_rows: errors.length,
466
- errors: errors,
467
- mutations: mutations,
468
- });
469
- if (logErr)
470
- console.warn("Import log opslaan mislukt", logErr);
471
- else
472
- void loadPastRuns();
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
- catch (logE) {
475
- console.warn("Import log opslaan mislukt", logE);
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
- if (updated + created > 0) {
478
- toast.success(`${updated} bijgewerkt · ${created} nieuw`);
479
- await onImported();
410
+ else {
411
+ setBanner({ kind: "error", text: data?.message ?? "Import kon niet starten." });
480
412
  }
481
- if (errors.length && updated + created === 0) {
482
- toast.error("Geen artikelen verwerkt");
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
- toast.error(`Import mislukt: ${e.message}`);
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
- setImporting(false);
490
- if (fileRef.current)
491
- fileRef.current.value = "";
468
+ setBusy(null);
469
+ setProgress(null);
470
+ void loadPastRuns();
492
471
  }
493
472
  };
494
- // Silence unused-import warning if updateDraftMeta remains for later use
495
- void updateDraftMeta;
496
- 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) => !importing && 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" }), " en alleen de voorraadvelden worden bijgewerkt. Onbekende artikelcodes worden als nieuwe artikelen aangemaakt en waar mogelijk automatisch gekoppeld aan Floriday."] })] }), _jsxs("div", { className: "space-y-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Checkbox, { id: "auto-publish", checked: autoPublish, onCheckedChange: (v) => setAutoPublish(Boolean(v)), disabled: importing }), _jsx(Label, { htmlFor: "auto-publish", className: "text-sm font-normal", children: "Nieuwe producten automatisch naar Floriday publiceren (indien alle verplichte gegevens aanwezig zijn)" })] }), _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: importing, onChange: (e) => {
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
- } }), importing && (_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}%` } }) }), _jsxs("p", { className: "text-xs text-muted-foreground flex items-center gap-2", children: [_jsx(Loader2, { className: "h-3 w-3 animate-spin" }), "Bezig met importeren\u2026 ", progress, "%"] })] })), result && (_jsxs("div", { className: "rounded-md border bg-muted/30 p-3 text-sm space-y-2", children: [_jsx("div", { className: "font-medium", children: "Importoverzicht" }), _jsxs("ul", { className: "text-xs space-y-1", children: [_jsxs("li", { children: ["Bestaande artikelen bijgewerkt:", " ", _jsx("span", { className: "tabular-nums font-medium text-foreground", children: result.updated })] }), _jsxs("li", { children: ["Nieuwe artikelen aangemaakt:", " ", _jsx("span", { className: "tabular-nums font-medium text-foreground", children: result.created })] }), _jsxs("li", { children: ["Automatisch gekoppeld met Floriday:", " ", _jsx("span", { className: "tabular-nums font-medium text-foreground", children: result.linked })] }), _jsxs("li", { children: ["Gepubliceerd naar Floriday:", " ", _jsx("span", { className: "tabular-nums font-medium text-foreground", children: result.published })] }), _jsxs("li", { children: ["Wacht op Floriday-publicatie (gegevens incompleet):", " ", _jsx("span", { className: "tabular-nums font-medium text-foreground", children: result.waiting })] }), _jsxs("li", { children: ["Fouten:", " ", _jsx("span", { className: "tabular-nums font-medium text-foreground", children: result.errors.length })] })] }), result.errors.length > 0 && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "max-h-40 overflow-y-auto rounded border bg-background p-2 text-xs space-y-1", children: [result.errors.slice(0, 20).map((e, i) => (_jsxs("div", { className: "text-destructive", children: ["Regel ", e.row, " \u2014 ", e.articleCode || "(geen code)", " \u2014", " ", e.reden] }, i))), result.errors.length > 20 && (_jsxs("div", { className: "text-muted-foreground", children: ["\u2026 en ", result.errors.length - 20, " meer. Download het foutbestand voor de volledige lijst."] }))] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: downloadErrors, children: [_jsx(Download, { className: "mr-2 h-4 w-4" }), "Download foutbestand"] })] }))] })), !importing && 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: "Recente imports" }), _jsx("ul", { className: "text-xs space-y-1 max-h-48 overflow-y-auto", children: pastRuns.map((r) => (_jsxs("li", { className: "flex justify-between gap-2 border-b last:border-0 py-1", 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: [r.updated_rows, "\u21BB \u00B7 ", r.created_rows, "+ \u00B7 ", r.skipped_rows, "\u2715"] })] }, r.id))) })] }))] }), _jsx(DialogFooter, { children: _jsx(Button, { variant: "ghost", onClick: () => {
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
- setResult(null);
503
- }, disabled: importing, children: "Sluiten" }) })] }) })] }));
533
+ setPrepared(null);
534
+ setBanner(null);
535
+ }, disabled: !!busy, children: "Sluiten" }) })] }) })] }));
504
536
  }