@flowselections/floriday-voorraad 1.0.110 → 1.0.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist-lib/components/voorraad/FustenPage.d.ts.map +1 -1
  2. package/dist-lib/components/voorraad/FustenPage.js +10 -6
  3. package/dist-lib/components/voorraad/PriceStatusBadge.d.ts.map +1 -1
  4. package/dist-lib/components/voorraad/PriceStatusBadge.js +6 -1
  5. package/dist-lib/components/voorraad/VoorraadPage.d.ts.map +1 -1
  6. package/dist-lib/components/voorraad/VoorraadPage.js +8 -37
  7. package/dist-lib/hooks/useNoPriceProducts.d.ts +10 -0
  8. package/dist-lib/hooks/useNoPriceProducts.d.ts.map +1 -0
  9. package/dist-lib/hooks/useNoPriceProducts.js +56 -0
  10. package/dist-lib/hooks/useStockPushJobs.d.ts.map +1 -1
  11. package/dist-lib/hooks/useStockPushJobs.js +6 -1
  12. package/dist-lib/hooks/useStockSyncHealth.d.ts.map +1 -1
  13. package/dist-lib/hooks/useStockSyncHealth.js +2 -9
  14. package/dist-lib/hooks/useVoorraadData.d.ts.map +1 -1
  15. package/dist-lib/hooks/useVoorraadData.js +45 -24
  16. package/dist-lib/integrations/supabase/types.d.ts +818 -122
  17. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  18. package/dist-lib/integrations/supabase/types.js +1 -0
  19. package/dist-lib/lib/__tests__/fust-dimensions.test.d.ts +2 -0
  20. package/dist-lib/lib/__tests__/fust-dimensions.test.d.ts.map +1 -0
  21. package/dist-lib/lib/__tests__/fust-dimensions.test.js +27 -0
  22. package/dist-lib/lib/__tests__/request-guard.test.d.ts +2 -0
  23. package/dist-lib/lib/__tests__/request-guard.test.d.ts.map +1 -0
  24. package/dist-lib/lib/__tests__/request-guard.test.js +50 -0
  25. package/dist-lib/lib/fusten.d.ts +14 -3
  26. package/dist-lib/lib/fusten.d.ts.map +1 -1
  27. package/dist-lib/lib/fusten.js +38 -7
  28. package/dist-lib/lib/request-guard.d.ts +46 -0
  29. package/dist-lib/lib/request-guard.d.ts.map +1 -0
  30. package/dist-lib/lib/request-guard.js +100 -0
  31. package/package.json +1 -1
  32. package/sql/floriday-voorraad-schema.sql +186 -27
@@ -1 +1 @@
1
- {"version":3,"file":"FustenPage.d.ts","sourceRoot":"","sources":["../../../src/components/voorraad/FustenPage.tsx"],"names":[],"mappings":"AA4KA,wBAAgB,UAAU,gCA6PzB"}
1
+ {"version":3,"file":"FustenPage.d.ts","sourceRoot":"","sources":["../../../src/components/voorraad/FustenPage.tsx"],"names":[],"mappings":"AAgLA,wBAAgB,UAAU,gCAiQzB"}
@@ -5,30 +5,30 @@ import { Button, Card, Input, Badge, toast } from "@flowselections/core";
5
5
  import { supabase } from "../../integrations/supabase/client.js";
6
6
  import { useFloridayConnection } from "../../hooks/useFloridayConnection.js";
7
7
  import { useFusten } from "../../hooks/useFusten.js";
8
- import { fetchFloricodeFustMap, fetchFustMutations, formatFustDimensions, resolveFustDimensions, } from "../../lib/fusten.js";
8
+ import { fetchFloricodeFustMap, fetchFloridayCustomPackageMap, fetchFustMutations, formatFustDimensions, resolveFustDimensions, } from "../../lib/fusten.js";
9
9
  /** Afmetingen tonen en handmatig aanvullen wanneer de catalogus ze niet kent. */
10
- function DimensionCell({ fust, floricode, onSave, }) {
10
+ function DimensionCell({ fust, floricode, customPackages, onSave, }) {
11
11
  const [editing, setEditing] = useState(false);
12
12
  const [draft, setDraft] = useState({
13
13
  length_mm: fust.length_mm?.toString() ?? "",
14
14
  width_mm: fust.width_mm?.toString() ?? "",
15
15
  height_mm: fust.height_mm?.toString() ?? "",
16
16
  });
17
- const resolved = resolveFustDimensions(fust, floricode);
17
+ const resolved = resolveFustDimensions(fust, floricode, customPackages);
18
18
  const label = formatFustDimensions(resolved);
19
19
  const toNum = (v) => {
20
20
  const n = Math.trunc(Number(v));
21
21
  return Number.isFinite(n) && n > 0 ? n : null;
22
22
  };
23
23
  if (!editing) {
24
- return (_jsxs("div", { children: [_jsx("div", { className: label ? "tabular-nums" : "text-muted-foreground", children: label ?? "Onbekend" }), resolved.source !== "onbekend" && (_jsx("div", { className: "text-xs text-muted-foreground", children: resolved.source === "floricode" ? "Floricode" : "handmatig" })), _jsx("button", { type: "button", className: "text-xs text-primary hover:underline", onClick: () => {
24
+ return (_jsxs("div", { children: [_jsx("div", { className: label ? "tabular-nums" : "text-muted-foreground", children: label ?? "Onbekend" }), resolved.source !== "onbekend" && (_jsx("div", { className: "text-xs text-muted-foreground", children: resolved.source === "floricode" ? "Floricode" : resolved.source === "floriday" ? "Floriday eigen fust" : "handmatig" })), _jsx("button", { type: "button", className: "text-xs text-primary hover:underline", onClick: () => {
25
25
  setDraft({
26
26
  length_mm: fust.length_mm?.toString() ?? "",
27
27
  width_mm: fust.width_mm?.toString() ?? "",
28
28
  height_mm: fust.height_mm?.toString() ?? "",
29
29
  });
30
30
  setEditing(true);
31
- }, children: resolved.source === "handmatig" ? "Wijzigen" : resolved.source === "floricode" ? "Handmatig aanvullen" : "Afmeting toevoegen" })] }));
31
+ }, children: resolved.source === "handmatig" ? "Wijzigen" : resolved.source === "floricode" || resolved.source === "floriday" ? "Handmatig aanvullen" : "Afmeting toevoegen" })] }));
32
32
  }
33
33
  return (_jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-center gap-1", children: [["length_mm", "width_mm", "height_mm"].map((k, i) => (_jsxs("div", { className: "flex items-center gap-1", children: [i > 0 && _jsx("span", { className: "text-muted-foreground", children: "\u00D7" }), _jsx(Input, { type: "number", className: "h-8 w-20 text-right", placeholder: ["L", "B", "H"][i], value: draft[k], onChange: (e) => setDraft((d) => ({ ...d, [k]: e.target.value })) })] }, k))), _jsx("span", { className: "text-xs text-muted-foreground", children: "mm" })] }), _jsxs("div", { className: "flex gap-1", children: [_jsx(Button, { size: "sm", className: "h-7", onClick: async () => {
34
34
  await onSave({
@@ -73,7 +73,11 @@ export function FustenPage() {
73
73
  fetchFloricodeFustMap()
74
74
  .then((m) => { setFloricode(m); setFloricodeError(null); })
75
75
  .catch((e) => setFloricodeError(e.message));
76
+ fetchFloridayCustomPackageMap()
77
+ .then(setCustomPackages)
78
+ .catch(() => setCustomPackages(new Map()));
76
79
  }, []);
80
+ const [customPackages, setCustomPackages] = useState(null);
77
81
  const [busyId, setBusyId] = useState(null);
78
82
  const [amounts, setAmounts] = useState({});
79
83
  const [negative, setNegative] = useState([]);
@@ -115,7 +119,7 @@ export function FustenPage() {
115
119
  void loadNegative();
116
120
  }
117
121
  };
118
- return (_jsxs("main", { className: "p-6 space-y-6", children: [_jsxs("header", { className: "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight truncate", children: "Fusten" }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: "Alle fusttypen worden automatisch afgeleid uit de beladingen van je producten." })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => void sync(), disabled: syncing, children: [_jsx(RefreshCw, { className: `mr-2 h-4 w-4 ${syncing ? "animate-spin" : ""}` }), "Synchroniseren"] })] }), error && (_jsxs("div", { className: "rounded-md border border-destructive/40 bg-destructive/10 text-destructive p-3 text-sm", children: ["Fusten laden mislukt: ", error] })), floricodeError && (_jsxs("div", { className: "rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm", children: ["Floricode-fustcodes niet leesbaar: ", floricodeError, ". Alleen handmatig ingevoerde maten worden getoond."] })), negative.length > 0 && (_jsxs("div", { className: "rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive space-y-1", children: [_jsxs("p", { className: "font-medium", children: [negative.length, " fusttype", negative.length === 1 ? " heeft" : "n hebben", " een tekort."] }), _jsx("p", { className: "text-foreground/80", children: "Er is meer uitgeleverd dan ingeboekt. Controleer de ontvangsten en retouren, of boek de werkelijke telling bij." }), _jsx("ul", { className: "text-xs text-foreground/80", children: negative.map((n) => (_jsxs("li", { children: [n.name.trim(), ": tekort van ", n.shortage.toLocaleString("nl-NL"), " fusten", n.duplicate_names > 0 ? " (dit fusttype bestaat meerdere keren)" : ""] }, n.fust_type_id))) })] })), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs(Card, { className: "p-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "Fusttypen" }), _jsx("p", { className: "mt-2 text-3xl font-bold tabular-nums", children: totals.types })] }), _jsxs(Card, { className: "p-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "Totale fustvoorraad" }), _jsx("p", { className: "mt-2 text-3xl font-bold tabular-nums", children: totals.stock })] }), _jsxs(Card, { className: "p-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "Onder minimum" }), _jsx("p", { className: "mt-2 text-3xl font-bold tabular-nums", children: totals.belowMin })] })] }), _jsxs("div", { className: "relative max-w-sm", children: [_jsx(Search, { className: "absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: search, onChange: (e) => setSearch(e.target.value), placeholder: "Zoek fust\u2026", className: "pl-9" })] }), _jsx(Card, { className: "overflow-hidden", children: loading ? (_jsx("p", { className: "p-6 text-sm text-muted-foreground", children: "Fusten laden\u2026" })) : filtered.length === 0 ? (_jsxs("div", { className: "p-6 text-sm text-muted-foreground flex items-center gap-2", children: [_jsx(Boxes, { className: "h-4 w-4" }), "Nog geen fusttypen gevonden. Voeg een belading toe aan een product."] })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-muted/50 text-left", children: _jsxs("tr", { children: [_jsx("th", { className: "p-3", children: "Fust" }), _jsx("th", { className: "p-3", children: "Bron" }), _jsx("th", { className: "p-3", children: "Afmeting (L \u00D7 B \u00D7 H)" }), _jsx("th", { className: "p-3 text-right", children: "Gebruikt door" }), _jsx("th", { className: "p-3 text-right", children: "Voorraad" }), _jsx("th", { className: "p-3 text-right", children: "Minimum" }), _jsx("th", { className: "p-3 text-right", children: "Aanpassen" })] }) }), _jsx("tbody", { children: filtered.map((f) => (_jsxs("tr", { className: "border-t border-border align-top", children: [_jsxs("td", { className: "p-3", children: [_jsx("div", { className: "font-medium", children: f.name }), _jsx("div", { className: "text-xs text-muted-foreground", children: f.vbn_code ? `VBN ${f.vbn_code}` : f.fust_key }), !f.in_use && (_jsx(Badge, { variant: "outline", className: "mt-1", children: "Niet meer in gebruik" })), _jsx("div", { className: "mt-1", children: _jsx(MutationHistory, { fust: f }) })] }), _jsx("td", { className: "p-3", children: f.source === "SUPPLIER" ? "Leverancier" : "VBN" }), _jsx("td", { className: "p-3 text-sm", children: _jsx(DimensionCell, { fust: f, floricode: floricode, onSave: (v) => patch(f.id, v) }) }), _jsx("td", { className: "p-3 text-right tabular-nums", children: f.used_by_count }), _jsx("td", { className: "p-3 text-right", children: f.stock < 0 ? (_jsxs("span", { className: "tabular-nums font-semibold text-rose-600", title: "Er is meer uitgeleverd dan ingeboekt. Boek ontvangen of geretourneerde fusten bij.", children: ["Tekort van ", Math.abs(f.stock).toLocaleString("nl-NL"), " fusten"] })) : (_jsx("span", { className: `tabular-nums font-semibold ${f.minimum_stock > 0 && f.stock < f.minimum_stock
122
+ return (_jsxs("main", { className: "p-6 space-y-6", children: [_jsxs("header", { className: "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-4", children: [_jsxs("div", { className: "min-w-0", children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight truncate", children: "Fusten" }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: "Alle fusttypen worden automatisch afgeleid uit de beladingen van je producten." })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => void sync(), disabled: syncing, children: [_jsx(RefreshCw, { className: `mr-2 h-4 w-4 ${syncing ? "animate-spin" : ""}` }), "Synchroniseren"] })] }), error && (_jsxs("div", { className: "rounded-md border border-destructive/40 bg-destructive/10 text-destructive p-3 text-sm", children: ["Fusten laden mislukt: ", error] })), floricodeError && (_jsxs("div", { className: "rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm", children: ["Floricode-fustcodes niet leesbaar: ", floricodeError, ". Alleen handmatig ingevoerde maten worden getoond."] })), negative.length > 0 && (_jsxs("div", { className: "rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive space-y-1", children: [_jsxs("p", { className: "font-medium", children: [negative.length, " fusttype", negative.length === 1 ? " heeft" : "n hebben", " een tekort."] }), _jsx("p", { className: "text-foreground/80", children: "Er is meer uitgeleverd dan ingeboekt. Controleer de ontvangsten en retouren, of boek de werkelijke telling bij." }), _jsx("ul", { className: "text-xs text-foreground/80", children: negative.map((n) => (_jsxs("li", { children: [n.name.trim(), ": tekort van ", n.shortage.toLocaleString("nl-NL"), " fusten", n.duplicate_names > 0 ? " (dit fusttype bestaat meerdere keren)" : ""] }, n.fust_type_id))) })] })), _jsxs("div", { className: "grid gap-4 sm:grid-cols-3", children: [_jsxs(Card, { className: "p-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "Fusttypen" }), _jsx("p", { className: "mt-2 text-3xl font-bold tabular-nums", children: totals.types })] }), _jsxs(Card, { className: "p-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "Totale fustvoorraad" }), _jsx("p", { className: "mt-2 text-3xl font-bold tabular-nums", children: totals.stock })] }), _jsxs(Card, { className: "p-4", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "Onder minimum" }), _jsx("p", { className: "mt-2 text-3xl font-bold tabular-nums", children: totals.belowMin })] })] }), _jsxs("div", { className: "relative max-w-sm", children: [_jsx(Search, { className: "absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { value: search, onChange: (e) => setSearch(e.target.value), placeholder: "Zoek fust\u2026", className: "pl-9" })] }), _jsx(Card, { className: "overflow-hidden", children: loading ? (_jsx("p", { className: "p-6 text-sm text-muted-foreground", children: "Fusten laden\u2026" })) : filtered.length === 0 ? (_jsxs("div", { className: "p-6 text-sm text-muted-foreground flex items-center gap-2", children: [_jsx(Boxes, { className: "h-4 w-4" }), "Nog geen fusttypen gevonden. Voeg een belading toe aan een product."] })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-muted/50 text-left", children: _jsxs("tr", { children: [_jsx("th", { className: "p-3", children: "Fust" }), _jsx("th", { className: "p-3", children: "Bron" }), _jsx("th", { className: "p-3", children: "Afmeting (L \u00D7 B \u00D7 H)" }), _jsx("th", { className: "p-3 text-right", children: "Gebruikt door" }), _jsx("th", { className: "p-3 text-right", children: "Voorraad" }), _jsx("th", { className: "p-3 text-right", children: "Minimum" }), _jsx("th", { className: "p-3 text-right", children: "Aanpassen" })] }) }), _jsx("tbody", { children: filtered.map((f) => (_jsxs("tr", { className: "border-t border-border align-top", children: [_jsxs("td", { className: "p-3", children: [_jsx("div", { className: "font-medium", children: f.name }), _jsx("div", { className: "text-xs text-muted-foreground", children: f.vbn_code ? `VBN ${f.vbn_code}` : f.fust_key }), !f.in_use && (_jsx(Badge, { variant: "outline", className: "mt-1", children: "Niet meer in gebruik" })), _jsx("div", { className: "mt-1", children: _jsx(MutationHistory, { fust: f }) })] }), _jsx("td", { className: "p-3", children: f.source === "SUPPLIER" ? "Leverancier" : "VBN" }), _jsx("td", { className: "p-3 text-sm", children: _jsx(DimensionCell, { fust: f, floricode: floricode, customPackages: customPackages, onSave: (v) => patch(f.id, v) }) }), _jsx("td", { className: "p-3 text-right tabular-nums", children: f.used_by_count }), _jsx("td", { className: "p-3 text-right", children: f.stock < 0 ? (_jsxs("span", { className: "tabular-nums font-semibold text-rose-600", title: "Er is meer uitgeleverd dan ingeboekt. Boek ontvangen of geretourneerde fusten bij.", children: ["Tekort van ", Math.abs(f.stock).toLocaleString("nl-NL"), " fusten"] })) : (_jsx("span", { className: `tabular-nums font-semibold ${f.minimum_stock > 0 && f.stock < f.minimum_stock
119
123
  ? "text-rose-600"
120
124
  : ""}`, children: f.stock })) }), _jsx("td", { className: "p-3 text-right", children: _jsx(Input, { type: "number", className: "h-8 w-28 text-right", defaultValue: f.minimum_stock, onBlur: (e) => {
121
125
  const v = Math.max(0, Math.trunc(Number(e.target.value) || 0));
@@ -1 +1 @@
1
- {"version":3,"file":"PriceStatusBadge.d.ts","sourceRoot":"","sources":["../../../src/components/voorraad/PriceStatusBadge.tsx"],"names":[],"mappings":"AAuFA,KAAK,KAAK,GAAG;IACX,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACvC,4FAA4F;IAC5F,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAeF,wBAAgB,gBAAgB,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,YAAY,EAAE,EAAE,KAAK,sCAmG/E"}
1
+ {"version":3,"file":"PriceStatusBadge.d.ts","sourceRoot":"","sources":["../../../src/components/voorraad/PriceStatusBadge.tsx"],"names":[],"mappings":"AA2FA,KAAK,KAAK,GAAG;IACX,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACvC,4FAA4F;IAC5F,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,YAAY,EAAE,OAAO,CAAC;CACvB,CAAC;AAeF,wBAAgB,gBAAgB,CAAC,EAAE,WAAW,EAAE,SAAS,EAAE,YAAY,EAAE,EAAE,KAAK,sCAmG/E"}
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { isTabHidden } from "../../lib/request-guard.js";
2
3
  import { CircleDollarSign, Clock, MinusCircle, Loader2 } from "lucide-react";
3
4
  import { useEffect, useState } from "react";
4
5
  import { useTradeItemSupplyLine } from "../../hooks/useTradeItemSupplyLine.js";
@@ -36,7 +37,11 @@ function useLastSentPrice(tradeItemId, productId) {
36
37
  : null);
37
38
  };
38
39
  void load();
39
- const t = setInterval(load, 30000);
40
+ // Alleen opvragen als het tabblad zichtbaar is (1.0.111).
41
+ const t = setInterval(() => {
42
+ if (!isTabHidden())
43
+ void load();
44
+ }, 30000);
40
45
  return () => {
41
46
  cancelled = true;
42
47
  clearInterval(t);
@@ -1 +1 @@
1
- {"version":3,"file":"VoorraadPage.d.ts","sourceRoot":"","sources":["../../../src/components/voorraad/VoorraadPage.tsx"],"names":[],"mappings":"AA2GA,wBAAgB,YAAY,gCAsX3B"}
1
+ {"version":3,"file":"VoorraadPage.d.ts","sourceRoot":"","sources":["../../../src/components/voorraad/VoorraadPage.tsx"],"names":[],"mappings":"AA+GA,wBAAgB,YAAY,gCA2W3B"}
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useEffect, useMemo, useState } from "react";
2
+ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
3
+ import { useNoPriceProducts } from "../../hooks/useNoPriceProducts.js";
4
+ import { pageRequestGuard } from "../../lib/request-guard.js";
5
+ const EMPTY_TRIPS = [];
3
6
  import { Link } from "@tanstack/react-router";
4
7
  import { Button, Card, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, useAuthState, } from "@flowselections/core";
5
8
  import { AlertTriangle, CheckCircle2, FileText, PackageX, Plus, Search, } from "lucide-react";
6
9
  import { useFloridayConnection } from "../../hooks/useFloridayConnection.js";
7
10
  import { FloridayConnectionNotice } from "./FloridayConnectionNotice.js";
8
- import { supabase } from "../../integrations/supabase/client.js";
9
11
  import { useVoorraadData } from "../../hooks/useVoorraadData.js";
10
12
  import { StockSyncStatus } from "./StockSyncStatus.js";
11
13
  import { StockIntegrityAlert } from "./StockIntegrityAlert.js";
@@ -58,41 +60,10 @@ export function VoorraadPage() {
58
60
  const [drawerIds, setDrawerIds] = useState([]);
59
61
  const [drawerOpen, setDrawerOpen] = useState(false);
60
62
  // Artikelen zonder geldige prijs per stuk (eenheid "stuks", prijs > 0), op id.
61
- const [noPriceIds, setNoPriceIds] = useState(new Set());
62
- const [noPriceBreakdown, setNoPriceBreakdown] = useState({ empty: 0, zero: 0, unit: 0 });
63
+ // Los van de identiteit van `items`: dat effect veroorzaakte een opvraag-lus (1.0.111).
64
+ const { noPriceIds, noPriceBreakdown } = useNoPriceProducts();
63
65
  const [noPriceOnly, setNoPriceOnly] = useState(false);
64
- useEffect(() => {
65
- let cancelled = false;
66
- void supabase
67
- .from("products")
68
- .select("id, unit, sale_price")
69
- .then(({ data }) => {
70
- if (cancelled)
71
- return;
72
- const s = new Set();
73
- const b = { empty: 0, zero: 0, unit: 0 };
74
- for (const p of (data ?? [])) {
75
- const isPiece = (p.unit ?? "").trim().toLowerCase() === "stuks";
76
- if (!isPiece) {
77
- s.add(p.id);
78
- b.unit++;
79
- }
80
- else if (p.sale_price == null) {
81
- s.add(p.id);
82
- b.empty++;
83
- }
84
- else if (!(Number(p.sale_price) > 0)) {
85
- s.add(p.id);
86
- b.zero++;
87
- }
88
- }
89
- setNoPriceIds(s);
90
- setNoPriceBreakdown(b);
91
- });
92
- return () => {
93
- cancelled = true;
94
- };
95
- }, [items]);
66
+ const guardTrips = useSyncExternalStore(pageRequestGuard.subscribe, () => pageRequestGuard.trips(), () => EMPTY_TRIPS);
96
67
  const toggleSelect = (id) => setSelected((cur) => {
97
68
  const next = new Set(cur);
98
69
  if (next.has(id))
@@ -162,7 +133,7 @@ export function VoorraadPage() {
162
133
  { id: "out_of_stock", label: "Niet op voorraad", count: counts.outOfStock },
163
134
  { id: "below_min", label: "Onder minimum", count: counts.belowMin },
164
135
  ];
165
- return (_jsxs("main", { className: "p-6 space-y-6", children: [_jsxs("header", { className: "flex flex-wrap items-start justify-between gap-4", children: [_jsxs("div", { className: "min-w-[12rem] flex-1", children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: "Voorraad" }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: "Beheer je artikelen en voorraadniveaus." })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsx(Link, { to: "/voorraad/validatie", children: "Validatie" }) }), _jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsx(Link, { to: "/voorraad/publicaties", children: "Publicaties" }) }), _jsx(Button, { asChild: true, size: "sm", children: _jsxs(Link, { to: "/voorraad/nieuw", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Artikel toevoegen"] }) })] })] }), _jsx(StockIntegrityAlert, {}), _jsx(SupplyReadScopeAlert, {}), _jsx(StockSyncStatus, {}), noPriceIds.size > 0 && (_jsxs("div", { className: "flex flex-wrap items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-800", children: [_jsx(AlertTriangle, { className: "mt-0.5 h-4 w-4 shrink-0" }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("span", { className: "font-medium", children: [noPriceIds.size, " artikel", noPriceIds.size === 1 ? " staat" : "en staan", " niet in Floriday zonder geldige prijs:", " ", [
136
+ return (_jsxs("main", { className: "p-6 space-y-6", children: [_jsxs("header", { className: "flex flex-wrap items-start justify-between gap-4", children: [_jsxs("div", { className: "min-w-[12rem] flex-1", children: [_jsx("h1", { className: "text-2xl font-bold tracking-tight", children: "Voorraad" }), _jsx("p", { className: "text-sm text-muted-foreground mt-1", children: "Beheer je artikelen en voorraadniveaus." })] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsx(Link, { to: "/voorraad/validatie", children: "Validatie" }) }), _jsx(Button, { asChild: true, variant: "outline", size: "sm", children: _jsx(Link, { to: "/voorraad/publicaties", children: "Publicaties" }) }), _jsx(Button, { asChild: true, size: "sm", children: _jsxs(Link, { to: "/voorraad/nieuw", children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), "Artikel toevoegen"] }) })] })] }), guardTrips.length > 0 && (_jsxs("div", { role: "alert", className: "rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive", children: ["Deze pagina vroeg te vaak gegevens op en is daarmee gestopt. Open de pagina opnieuw.", _jsxs("details", { className: "text-xs opacity-80", children: [_jsx("summary", { className: "cursor-pointer", children: "Details voor beheer" }), guardTrips.map((t) => (_jsxs("div", { children: [t.table, ": ", t.count, " aanvragen binnen 10 s om ", new Date(t.at).toLocaleTimeString("nl-NL")] }, t.table)))] })] })), _jsx(StockIntegrityAlert, {}), _jsx(SupplyReadScopeAlert, {}), _jsx(StockSyncStatus, {}), noPriceIds.size > 0 && (_jsxs("div", { className: "flex flex-wrap items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-800", children: [_jsx(AlertTriangle, { className: "mt-0.5 h-4 w-4 shrink-0" }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("span", { className: "font-medium", children: [noPriceIds.size, " artikel", noPriceIds.size === 1 ? " staat" : "en staan", " niet in Floriday zonder geldige prijs:", " ", [
166
137
  noPriceBreakdown.empty > 0 ? `${noPriceBreakdown.empty} zonder verkoopprijs` : null,
167
138
  noPriceBreakdown.zero > 0 ? `${noPriceBreakdown.zero} met prijs € 0,00` : null,
168
139
  noPriceBreakdown.unit > 0 ? `${noPriceBreakdown.unit} met een andere eenheid dan stuks` : null,
@@ -0,0 +1,10 @@
1
+ export type NoPriceBreakdown = {
2
+ empty: number;
3
+ zero: number;
4
+ unit: number;
5
+ };
6
+ export declare function useNoPriceProducts(): {
7
+ noPriceIds: Set<string>;
8
+ noPriceBreakdown: NoPriceBreakdown;
9
+ };
10
+ //# sourceMappingURL=useNoPriceProducts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useNoPriceProducts.d.ts","sourceRoot":"","sources":["../../src/hooks/useNoPriceProducts.ts"],"names":[],"mappings":"AASA,MAAM,MAAM,gBAAgB,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAE7E,wBAAgB,kBAAkB;;;EA+CjC"}
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Artikelen zonder geldige prijs per stuk. Eén keer bij openen, daarna alleen na een
3
+ * echte wijziging aan products (realtime), gebundeld tot hooguit 1 aanvraag per 5 s.
4
+ * Bewust los van de identiteit van de voorraadlijst (die veroorzaakte een opvraag-lus).
5
+ */
6
+ import { useEffect, useState } from "react";
7
+ import { supabase } from "../integrations/supabase/client.js";
8
+ import { createBundler, pageRequestGuard } from "../lib/request-guard.js";
9
+ export function useNoPriceProducts() {
10
+ const [ids, setIds] = useState(new Set());
11
+ const [breakdown, setBreakdown] = useState({ empty: 0, zero: 0, unit: 0 });
12
+ useEffect(() => {
13
+ let cancelled = false;
14
+ const load = async () => {
15
+ if (!pageRequestGuard.allow("products"))
16
+ return;
17
+ const { data } = await supabase.from("products").select("id, unit, sale_price");
18
+ if (cancelled)
19
+ return;
20
+ const s = new Set();
21
+ const b = { empty: 0, zero: 0, unit: 0 };
22
+ for (const p of (data ?? [])) {
23
+ const isPiece = (p.unit ?? "").trim().toLowerCase() === "stuks";
24
+ if (!isPiece) {
25
+ s.add(p.id);
26
+ b.unit++;
27
+ }
28
+ else if (p.sale_price == null) {
29
+ s.add(p.id);
30
+ b.empty++;
31
+ }
32
+ else if (!(Number(p.sale_price) > 0)) {
33
+ s.add(p.id);
34
+ b.zero++;
35
+ }
36
+ }
37
+ setIds((cur) => (cur.size === s.size && [...s].every((x) => cur.has(x)) ? cur : s));
38
+ setBreakdown((cur) => cur.empty === b.empty && cur.zero === b.zero && cur.unit === b.unit ? cur : b);
39
+ };
40
+ void load();
41
+ const bundler = createBundler(() => void load(), 5000);
42
+ const channel = supabase
43
+ .channel(`voorraad-noprice-${Math.random().toString(36).slice(2)}`)
44
+ .on("postgres_changes", { event: "*", schema: "public", table: "products" }, () => bundler.trigger())
45
+ .subscribe();
46
+ const onVis = () => bundler.flushIfPending();
47
+ document.addEventListener("visibilitychange", onVis);
48
+ return () => {
49
+ cancelled = true;
50
+ bundler.cancel();
51
+ document.removeEventListener("visibilitychange", onVis);
52
+ supabase.removeChannel(channel);
53
+ };
54
+ }, []);
55
+ return { noPriceIds: ids, noPriceBreakdown: breakdown };
56
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"useStockPushJobs.d.ts","sourceRoot":"","sources":["../../src/hooks/useStockPushJobs.ts"],"names":[],"mappings":"AAQA,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,OAAO,GAAG,IAAI,CAAC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,qEAAqE;IACrE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAwBD,wBAAgB,gBAAgB,CAAC,KAAK,SAAK,yEAqD1C;AAED;;;GAGG;AACH,wBAAgB,sBAAsB;aACT,OAAO;aAAW,MAAM,GAAG,IAAI;UAoB3D"}
1
+ {"version":3,"file":"useStockPushJobs.d.ts","sourceRoot":"","sources":["../../src/hooks/useStockPushJobs.ts"],"names":[],"mappings":"AASA,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,OAAO,GAAG,IAAI,CAAC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,qEAAqE;IACrE,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAwBD,wBAAgB,gBAAgB,CAAC,KAAK,SAAK,yEAuD1C;AAED;;;GAGG;AACH,wBAAgB,sBAAsB;aACT,OAAO;aAAW,MAAM,GAAG,IAAI;UAsB3D"}
@@ -3,6 +3,7 @@
3
3
  * inclusief de artikelnaam uit de Floriday-cache. Bedoeld om een testpush te
4
4
  * kunnen volgen zonder databasekennis.
5
5
  */
6
+ import { MIN_POLL_MS } from "../lib/request-guard.js";
6
7
  import { useQuery } from "@tanstack/react-query";
7
8
  import { supabase } from "../integrations/supabase/client.js";
8
9
  /** Floriday levert namen soms als taalobject ({ nl: "…", en: "…" }). */
@@ -28,7 +29,9 @@ function nameFromCache(data) {
28
29
  export function useStockPushJobs(limit = 10) {
29
30
  return useQuery({
30
31
  queryKey: ["stock-push-jobs", limit],
31
- refetchInterval: 15000,
32
+ refetchInterval: MIN_POLL_MS,
33
+ refetchIntervalInBackground: false,
34
+ refetchOnWindowFocus: false,
32
35
  queryFn: async () => {
33
36
  const { data, error } = await supabase
34
37
  .from("floriday_continuous_stock_queue")
@@ -82,6 +85,8 @@ export function useCentralAuthBlockade() {
82
85
  return useQuery({
83
86
  queryKey: ["central-auth-blockade"],
84
87
  refetchInterval: 60000,
88
+ refetchIntervalInBackground: false,
89
+ refetchOnWindowFocus: false,
85
90
  queryFn: async () => {
86
91
  const since = new Date(Date.now() - 30 * 60000).toISOString();
87
92
  const { data, error } = await supabase
@@ -1 +1 @@
1
- {"version":3,"file":"useStockSyncHealth.d.ts","sourceRoot":"","sources":["../../src/hooks/useStockSyncHealth.ts"],"names":[],"mappings":"AAeA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,kEAAkE;IAClE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,YAAY,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,kDAAkD;IAClD,qBAAqB,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAS3E;AAED,wBAAgB,kBAAkB,2EAuGjC"}
1
+ {"version":3,"file":"useStockSyncHealth.d.ts","sourceRoot":"","sources":["../../src/hooks/useStockSyncHealth.ts"],"names":[],"mappings":"AAgBA,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,kEAAkE;IAClE,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,4DAA4D;IAC5D,YAAY,EAAE,MAAM,CAAC;IACrB,+DAA+D;IAC/D,YAAY,EAAE,MAAM,CAAC;IACrB,gFAAgF;IAChF,OAAO,EAAE,cAAc,EAAE,CAAC;IAC1B,kDAAkD;IAClD,qBAAqB,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAS3E;AAED,wBAAgB,kBAAkB,2EAyGjC"}
@@ -1,12 +1,3 @@
1
- /**
2
- * Gezondheid van de voorraad-push-wachtrij richting Floriday.
3
- *
4
- * Bewust een venster van 24 uur: oude, allang achterhaalde fouten mogen de
5
- * gebruiker niet eindeloos blijven waarschuwen. Regels die door een nieuwere
6
- * voorraadstand zijn vervangen (`superseded`) tellen niet mee.
7
- * Toont ook de meest voorkomende reden, het aantal artikelen en hoeveel
8
- * daarvan geen prijs in het weekaanbod hebben.
9
- */
10
1
  import { useQuery } from "@tanstack/react-query";
11
2
  import { supabase } from "../integrations/supabase/client.js";
12
3
  const STALE_MINUTES = 10;
@@ -29,6 +20,8 @@ export function useStockSyncHealth() {
29
20
  return useQuery({
30
21
  queryKey: ["stock-sync-health"],
31
22
  refetchInterval: 60000,
23
+ refetchIntervalInBackground: false,
24
+ refetchOnWindowFocus: false,
32
25
  queryFn: async () => {
33
26
  const since = new Date(Date.now() - WINDOW_HOURS * 3600000).toISOString();
34
27
  const { data, error } = await supabase
@@ -1 +1 @@
1
- {"version":3,"file":"useVoorraadData.d.ts","sourceRoot":"","sources":["../../src/hooks/useVoorraadData.ts"],"names":[],"mappings":"AAEA,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAsB5E;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI;;;;;EA+U1D"}
1
+ {"version":3,"file":"useVoorraadData.d.ts","sourceRoot":"","sources":["../../src/hooks/useVoorraadData.ts"],"names":[],"mappings":"AAGA,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAsB5E;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI;;;;;EA2U1D"}
@@ -1,4 +1,5 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { createBundler, pageRequestGuard } from "../lib/request-guard.js";
2
3
  import { supabase } from "../integrations/supabase/client.js";
3
4
  import { normaliseTradeItem } from "../lib/floriday-client.js";
4
5
  import { floridayInfoFromCache } from "../lib/floriday-info.js";
@@ -45,6 +46,8 @@ export function useVoorraadData(connectionId) {
45
46
  const [error, setError] = useState(null);
46
47
  const { items: localItems, loading: localLoading, reload: reloadLocal, } = useLocalTradeItems(connectionId);
47
48
  const loadPublished = useCallback(async () => {
49
+ if (!pageRequestGuard.allow("products-overzicht"))
50
+ return;
48
51
  setLoading(true);
49
52
  setError(null);
50
53
  try {
@@ -148,30 +151,46 @@ export function useVoorraadData(connectionId) {
148
151
  useEffect(() => {
149
152
  loadPublished();
150
153
  }, [loadPublished]);
154
+ // Realtime-seintjes gebundeld: hooguit één herlaadactie per 2 s, niet in een
155
+ // verborgen tabblad (wel bij terugkeren). Voorkomt opvraag-stormen (1.0.111).
156
+ const reloadRef = useRef({ stock: false, all: false });
151
157
  useEffect(() => {
152
- const channel = supabase
158
+ const bundler = createBundler(() => {
159
+ const r = reloadRef.current;
160
+ reloadRef.current = { stock: false, all: false };
161
+ if (r.all) {
162
+ loadPublished();
163
+ reloadLocal();
164
+ }
165
+ else if (r.stock && pageRequestGuard.allow("trade_item_stock")) {
166
+ fetchStockMap(connectionId).then(setStockMap).catch(() => { });
167
+ }
168
+ }, 2000);
169
+ const markAll = () => {
170
+ reloadRef.current.all = true;
171
+ bundler.trigger();
172
+ };
173
+ const markStock = () => {
174
+ reloadRef.current.stock = true;
175
+ bundler.trigger();
176
+ };
177
+ let channel = supabase
153
178
  .channel(`voorraad-stock-${connectionId ?? "eigen"}`)
154
- .on("postgres_changes", { event: "*", schema: "public", table: "trade_item_stock" }, () => {
155
- fetchStockMap(connectionId).then(setStockMap).catch(() => { });
156
- })
157
- .on("postgres_changes", { event: "*", schema: "public", table: "products" }, () => {
158
- loadPublished();
159
- reloadLocal();
160
- })
161
- .on("postgres_changes", { event: "*", schema: "public", table: "floriday_trade_items_cache", filter: `connection_id=eq.${connectionId}` }, () => {
162
- loadPublished();
163
- })
164
- .on("postgres_changes", { event: "*", schema: "public", table: "product_groups" }, () => {
165
- loadPublished();
166
- })
167
- .on("postgres_changes", { event: "*", schema: "public", table: "product_bom_items" }, () => {
168
- loadPublished();
169
- })
170
- .on("postgres_changes", { event: "*", schema: "public", table: "product_group_items" }, () => {
171
- loadPublished();
172
- })
173
- .subscribe();
179
+ .on("postgres_changes", { event: "*", schema: "public", table: "trade_item_stock" }, markStock)
180
+ .on("postgres_changes", { event: "*", schema: "public", table: "products" }, markAll);
181
+ if (connectionId) {
182
+ channel = channel.on("postgres_changes", { event: "*", schema: "public", table: "floriday_trade_items_cache", filter: `connection_id=eq.${connectionId}` }, markAll);
183
+ }
184
+ channel = channel
185
+ .on("postgres_changes", { event: "*", schema: "public", table: "product_groups" }, markAll)
186
+ .on("postgres_changes", { event: "*", schema: "public", table: "product_bom_items" }, markAll)
187
+ .on("postgres_changes", { event: "*", schema: "public", table: "product_group_items" }, markAll);
188
+ channel.subscribe();
189
+ const onVis = () => bundler.flushIfPending();
190
+ document.addEventListener("visibilitychange", onVis);
174
191
  return () => {
192
+ bundler.cancel();
193
+ document.removeEventListener("visibilitychange", onVis);
175
194
  supabase.removeChannel(channel);
176
195
  };
177
196
  }, [connectionId, loadPublished, reloadLocal]);
@@ -274,7 +293,8 @@ export function useVoorraadData(connectionId) {
274
293
  return i;
275
294
  return { ...i, floriday: info };
276
295
  };
277
- const merged = [
296
+ // Stabiele identiteit: alleen een nieuwe lijst als de onderliggende data verandert.
297
+ const merged = useMemo(() => [
278
298
  ...localItems.map((i) => applyProductGroupReservation(classify(attachFloriday(applyStock(i, lookupStock(stockMap, {
279
299
  tradeItemKey: i.tradeItemKey ?? null,
280
300
  floridayId: i.floridayId ?? null,
@@ -287,7 +307,8 @@ export function useVoorraadData(connectionId) {
287
307
  tradeItemKey: i.tradeItemKey ?? null,
288
308
  productId: i.productId ?? null,
289
309
  })))))),
290
- ];
310
+ // eslint-disable-next-line react-hooks/exhaustive-deps
311
+ ], [localItems, publishedItems, stockMap, typeSets, floridayInfoMap, floridayStockMap]);
291
312
  return {
292
313
  items: merged,
293
314
  loading: loading || localLoading,