@flowselections/mailbox-orders 1.0.9 → 1.0.10

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 (57) hide show
  1. package/dist-lib/components/MailboxAuthBanner.d.ts +16 -0
  2. package/dist-lib/components/MailboxAuthBanner.d.ts.map +1 -0
  3. package/dist-lib/components/MailboxAuthBanner.js +14 -0
  4. package/dist-lib/components/MailboxProcessedPage.d.ts.map +1 -1
  5. package/dist-lib/components/MailboxProcessedPage.js +10 -2
  6. package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
  7. package/dist-lib/components/MailboxProposalsPage.js +425 -104
  8. package/dist-lib/components/SearchableSelect.d.ts +1 -0
  9. package/dist-lib/components/SearchableSelect.d.ts.map +1 -1
  10. package/dist-lib/components/SearchableSelect.js +2 -1
  11. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts +2 -0
  12. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts.map +1 -0
  13. package/dist-lib/components/settings/MailboxAiPromptCard.js +39 -0
  14. package/dist-lib/index.d.ts.map +1 -1
  15. package/dist-lib/index.js +2 -0
  16. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
  17. package/dist-lib/integrations/supabase/auth-attacher.js +5 -1
  18. package/dist-lib/integrations/supabase/auth-middleware.d.ts +2 -11202
  19. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  20. package/dist-lib/integrations/supabase/auth-middleware.js +8 -7
  21. package/dist-lib/integrations/supabase/client.d.ts +16514 -7163
  22. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  23. package/dist-lib/integrations/supabase/client.server.d.ts +16516 -7164
  24. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  25. package/dist-lib/integrations/supabase/types.d.ts +16768 -7202
  26. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  27. package/dist-lib/integrations/supabase/types.js +59 -8
  28. package/dist-lib/lib/ai-config.server.d.ts +46 -0
  29. package/dist-lib/lib/ai-config.server.d.ts.map +1 -0
  30. package/dist-lib/lib/ai-config.server.js +99 -0
  31. package/dist-lib/lib/delivery-date.d.ts +37 -0
  32. package/dist-lib/lib/delivery-date.d.ts.map +1 -0
  33. package/dist-lib/lib/delivery-date.js +96 -0
  34. package/dist-lib/lib/load-carriers.d.ts +26 -0
  35. package/dist-lib/lib/load-carriers.d.ts.map +1 -0
  36. package/dist-lib/lib/load-carriers.js +73 -0
  37. package/dist-lib/lib/mailbox-ai-prompt.d.ts +9 -0
  38. package/dist-lib/lib/mailbox-ai-prompt.d.ts.map +1 -0
  39. package/dist-lib/lib/mailbox-ai-prompt.js +30 -0
  40. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts +23 -0
  41. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts.map +1 -0
  42. package/dist-lib/lib/mailbox-ai-settings.functions.js +58 -0
  43. package/dist-lib/lib/mailbox-ai.server.d.ts +49 -4
  44. package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
  45. package/dist-lib/lib/mailbox-ai.server.js +284 -6
  46. package/dist-lib/lib/mailbox-body.d.ts +7 -0
  47. package/dist-lib/lib/mailbox-body.d.ts.map +1 -0
  48. package/dist-lib/lib/mailbox-body.js +38 -0
  49. package/dist-lib/lib/mailbox-ingest.server.d.ts +76 -0
  50. package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -0
  51. package/dist-lib/lib/mailbox-ingest.server.js +863 -0
  52. package/dist-lib/lib/mailbox-template.functions.d.ts +7 -67213
  53. package/dist-lib/lib/mailbox-template.functions.d.ts.map +1 -1
  54. package/dist-lib/lib/mailbox.functions.d.ts +107 -190506
  55. package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
  56. package/dist-lib/lib/mailbox.functions.js +544 -270
  57. package/package.json +4 -3
@@ -4,10 +4,66 @@ import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
4
4
  import { useServerFn } from "@tanstack/react-start";
5
5
  import { Button, Badge, Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea, Switch, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, toast, } from "@flowselections/core";
6
6
  import { Check, X, Trash2, Plus, AlertTriangle, RefreshCw } from "lucide-react";
7
- import { listProposals, approveProposal, rejectProposal, listCustomersForPicker, listProductsForPicker, listDeliveryLocationsForCustomer, listPickupLocations, listCarriersForPicker, getOrderPickDays, getActiveOrdersMailbox, ingestEmailEvent, backfillRecentEvents, } from "../lib/mailbox.functions";
7
+ import { computeEarliestDelivery, isValidDeliveryDate } from "../lib/delivery-date";
8
+ import { listProposals, approveProposal, rejectProposal, listCustomersForPicker, listProductsForPicker, listDeliveryLocationsForCustomer, listPickupLocations, listCarriersForPicker, getOrderPickDays, getActiveOrdersMailbox, syncMailboxNow, getLastIngestRun, refreshProposalsForAgentChange, reparseProposalNow, listBeladingOptionsForProducts, listAllowedLoadCarriers, } from "../lib/mailbox.functions";
9
+ import { enabledLoadCarriers, normalizeAllowedLoadCarriers, EMPTY_LOAD_CARRIERS } from "../lib/load-carriers";
8
10
  import { listTemplateFields, listPlantTypesForPicker, listSuppliersForPicker, } from "../lib/mailbox-template.functions";
9
11
  import { SearchableSelect } from "./SearchableSelect";
12
+ import { MailboxAuthBanner } from "./MailboxAuthBanner";
10
13
  import { supabase } from "../integrations/supabase/client";
14
+ const beladingKey = (b) => `${b.plants_per_crate}x${b.crates_per_layer}x${b.layers_per_cart}|${b.crate_code ?? ""}|${b.carrier_code ?? ""}`;
15
+ const beladingLabel = (b) => `${b.plants_per_crate} planten/fust · ${b.crates_per_layer} fusten/laag · ${b.layers_per_cart} lagen`;
16
+ // Extra regel onder het label: fusten/kar + fusttype + kartype
17
+ const beladingHint = (b) => [
18
+ `${b.crates_per_layer * b.layers_per_cart} fusten/kar`,
19
+ b.crate_name ?? null,
20
+ b.carrier_name ?? null,
21
+ ].filter(Boolean).join(" · ");
22
+ /** Losse tekstvergelijking voor kartypes (code of label). */
23
+ const normCarrierText = (v) => v.toLowerCase().replace(/[^a-z0-9]+/g, "");
24
+ /**
25
+ * Alleen beladingen tonen waarvan het kartype door de Logistiek Engine is
26
+ * toegestaan. Beladingen zonder kartype worden niet getoond (behalve de
27
+ * standaardbelading van het product).
28
+ */
29
+ const beladingAllowedByEngine = (b, allowed) => {
30
+ if (allowed.length === 0)
31
+ return true; // Engine (nog) niet beschikbaar
32
+ const hay = [b.carrier_code, b.carrier_name].filter(Boolean).map((v) => normCarrierText(String(v)));
33
+ if (hay.length === 0)
34
+ return false;
35
+ return allowed.some((c) => {
36
+ const keys = [normCarrierText(c.code), normCarrierText(c.label)];
37
+ return keys.some((k) => k && hay.some((h) => h === k || h.includes(k) || k.includes(h)));
38
+ });
39
+ };
40
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
41
+ /**
42
+ * Zet een klantwaarde (id óf door AI geschreven klantnaam) om naar een geldig
43
+ * klant-id. Valt terug op de automatisch herkende klant van het voorstel.
44
+ */
45
+ function resolveCustomerId(raw, customers, fallbackId) {
46
+ const list = Array.isArray(customers) ? customers : [];
47
+ if (typeof raw === "string" && raw.trim()) {
48
+ const v = raw.trim();
49
+ if (UUID_RE.test(v))
50
+ return v;
51
+ const norm = v.toLowerCase();
52
+ const byName = list.find((c) => (c.company_name ?? "").trim().toLowerCase() === norm);
53
+ if (byName)
54
+ return byName.id;
55
+ const byEmail = list.find((c) => (c.email ?? "").trim().toLowerCase() === norm);
56
+ if (byEmail)
57
+ return byEmail.id;
58
+ const partial = list.find((c) => {
59
+ const n = (c.company_name ?? "").trim().toLowerCase();
60
+ return n.length > 2 && (n.includes(norm) || norm.includes(n));
61
+ });
62
+ if (partial)
63
+ return partial.id;
64
+ }
65
+ return fallbackId ?? null;
66
+ }
11
67
  export function MailboxProposalsPage() {
12
68
  const qc = useQueryClient();
13
69
  const fetchList = useServerFn(listProposals);
@@ -21,8 +77,8 @@ export function MailboxProposalsPage() {
21
77
  const fetchCarriers = useServerFn(listCarriersForPicker);
22
78
  const fetchPickupLocations = useServerFn(listPickupLocations);
23
79
  const fetchActive = useServerFn(getActiveOrdersMailbox);
24
- const fetchIngest = useServerFn(ingestEmailEvent);
25
- const fetchBackfill = useServerFn(backfillRecentEvents);
80
+ const fetchSync = useServerFn(syncMailboxNow);
81
+ const fetchLastRun = useServerFn(getLastIngestRun);
26
82
  // Server fns met requireSupabaseAuth gooien een raw Response bij een stale
27
83
  // bearer-token. Die bubbelt op als "[object Response]" en blankt het scherm.
28
84
  // Vang elke fout en geef een lege fallback terug — UI blijft bruikbaar.
@@ -37,9 +93,15 @@ export function MailboxProposalsPage() {
37
93
  // Bestelvoorstellen toont uitsluitend nieuwe, nog niet gecontroleerde
38
94
  // bestellingen ("Te controleren"). Goedgekeurd/afgewezen verschijnt in
39
95
  // de module Verwerkt.
40
- const { data, isLoading } = useQuery({
96
+ const { data, isLoading, error: listError, refetch: refetchList } = useQuery({
41
97
  queryKey: ["mailbox-proposals", "pending"],
42
- queryFn: () => safe(() => fetchList({ data: { status: "pending" } })),
98
+ // Bewust NIET stilzwijgend opvangen: als de server tijdelijk onbereikbaar is
99
+ // moet de gebruiker dat zien in plaats van een lege lijst ("geen mailtjes").
100
+ queryFn: () => fetchList({ data: { status: "pending" } }),
101
+ retry: 3,
102
+ retryDelay: (a) => Math.min(1000 * 2 ** a, 8000),
103
+ refetchInterval: 60000,
104
+ refetchOnWindowFocus: true,
43
105
  });
44
106
  const { data: templateData } = useQuery({
45
107
  queryKey: ["mailbox-template-fields"],
@@ -51,7 +113,6 @@ export function MailboxProposalsPage() {
51
113
  const { data: supplierData } = useQuery({ queryKey: ["mailbox-suppliers"], queryFn: () => safe(() => fetchSuppliers()) });
52
114
  const { data: carriersData } = useQuery({ queryKey: ["mailbox-carriers"], queryFn: () => safe(() => fetchCarriers()) });
53
115
  const { data: pickupLocData } = useQuery({ queryKey: ["mailbox-pickup-locations"], queryFn: () => safe(() => fetchPickupLocations()) });
54
- // Realtime ingestie + backfill — voorheen in de Inbox-pagina.
55
116
  const { data: activeMailbox } = useQuery({
56
117
  queryKey: ["mailbox-active-account"],
57
118
  queryFn: () => fetchActive(),
@@ -59,23 +120,27 @@ export function MailboxProposalsPage() {
59
120
  retry: 1,
60
121
  });
61
122
  const activeMailboxId = activeMailbox?.connected ? activeMailbox.id : null;
62
- const backfillMut = useMutation({
63
- mutationFn: async () => {
123
+ // Ingest wordt server-side gedreven (pg_cron, elke minuut). Deze knoppen /
124
+ // triggers hier zijn een extra vangnet zodat de UI direct bijwerkt — de
125
+ // pipeline is exact dezelfde als die van cron.
126
+ const syncMut = useMutation({
127
+ mutationFn: async (trigger) => {
64
128
  try {
65
- return await fetchBackfill();
129
+ return await fetchSync({ data: { trigger } });
66
130
  }
67
- catch {
68
- return { ok: false, inserted: 0 };
131
+ catch (e) {
132
+ return { ok: false, error: String(e?.message ?? e), inserted: 0, processed: 0, errors: 1 };
69
133
  }
70
134
  },
71
135
  onSuccess: (res) => {
72
- if (res?.ok && (res.inserted ?? 0) > 0) {
136
+ if (res?.ok && ((res.inserted ?? 0) > 0 || (res.processed ?? 0) > 0)) {
73
137
  qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
138
+ qc.invalidateQueries({ queryKey: ["mailbox-last-ingest-run"] });
74
139
  }
75
140
  },
76
141
  });
77
142
  const handleManualSync = () => {
78
- const p = backfillMut.mutateAsync();
143
+ const p = syncMut.mutateAsync("manual");
79
144
  toast.promise(p, {
80
145
  loading: "Mailbox synchroniseren...",
81
146
  success: (res) => res?.ok
@@ -86,6 +151,8 @@ export function MailboxProposalsPage() {
86
151
  error: "Sync mislukt",
87
152
  });
88
153
  };
154
+ // Realtime hint: zodra er een nieuw event binnenkomt vragen we de server om
155
+ // het meteen te verwerken (zelfde pipeline). Cron is de garantiestrook.
89
156
  useEffect(() => {
90
157
  if (!activeMailboxId)
91
158
  return;
@@ -94,36 +161,99 @@ export function MailboxProposalsPage() {
94
161
  .on("postgres_changes", {
95
162
  event: "INSERT", schema: "public", table: "email_auth_events",
96
163
  filter: `connection_id=eq.${activeMailboxId}`,
97
- }, async (payload) => {
98
- const evId = payload?.new?.id;
99
- if (!evId)
100
- return;
101
- try {
102
- const res = await fetchIngest({ data: { eventId: evId } });
103
- if (res?.ok && (res.inserted ?? 0) > 0) {
104
- toast.success("Nieuwe bestelling ontvangen");
105
- qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
106
- }
107
- }
108
- catch { /* backfill vangt op */ }
164
+ }, () => {
165
+ syncMut.mutate("realtime");
109
166
  })
110
167
  .subscribe();
111
168
  return () => { void supabase.removeChannel(channel); };
112
- }, [activeMailboxId, fetchIngest, qc]);
169
+ // eslint-disable-next-line react-hooks/exhaustive-deps
170
+ }, [activeMailboxId]);
171
+ // Bij eerste keer openen + elke 5 min extra vangnet naast pg_cron/realtime.
113
172
  const didInitialBackfill = useRef(false);
114
173
  useEffect(() => {
115
174
  if (!activeMailboxId || didInitialBackfill.current)
116
175
  return;
117
176
  didInitialBackfill.current = true;
118
- backfillMut.mutate();
119
- const t = setInterval(() => backfillMut.mutate(), 300000);
177
+ syncMut.mutate("manual");
178
+ const t = setInterval(() => syncMut.mutate("manual"), 300000);
120
179
  return () => clearInterval(t);
121
180
  // eslint-disable-next-line react-hooks/exhaustive-deps
122
181
  }, [activeMailboxId]);
182
+ // Laatste ingest-run tonen voor observability.
183
+ const { data: lastRunData } = useQuery({
184
+ queryKey: ["mailbox-last-ingest-run"],
185
+ queryFn: () => safe(() => fetchLastRun()),
186
+ refetchInterval: 60000,
187
+ });
188
+ const lastRun = lastRunData?.run ?? null;
189
+ // AI-agent gewijzigd? Dan worden openstaande voorstellen automatisch opnieuw
190
+ // geanalyseerd met de nieuwste agent. Server-side draait dit ook elke
191
+ // cron-run; hier reageren we direct op wijzigingen in de AI-module.
192
+ const fetchAgentRefresh = useServerFn(refreshProposalsForAgentChange);
193
+ const agentRefreshMut = useMutation({
194
+ mutationFn: async () => {
195
+ try {
196
+ return await fetchAgentRefresh();
197
+ }
198
+ catch {
199
+ return { reparsed: 0 };
200
+ }
201
+ },
202
+ onSuccess: (res) => {
203
+ if ((res?.reparsed ?? 0) > 0) {
204
+ qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
205
+ toast.success(`${res.reparsed} voorstel(len) opnieuw geanalyseerd met de bijgewerkte AI-agent`);
206
+ }
207
+ },
208
+ });
209
+ // Handmatig één voorstel opnieuw laten analyseren door de AI-agent.
210
+ const fetchReparse = useServerFn(reparseProposalNow);
211
+ const [reparseError, setReparseError] = useState(null);
212
+ const reparseMutation = useMutation({
213
+ mutationFn: async (proposalId) => {
214
+ setReparseError(null);
215
+ return (await fetchReparse({ data: { proposalId } }));
216
+ },
217
+ onSuccess: (res) => {
218
+ if (res?.ok) {
219
+ setReparseError(null);
220
+ qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
221
+ toast.success("E-mail opnieuw geanalyseerd — bestelgegevens bijgewerkt");
222
+ }
223
+ else {
224
+ const msg = res?.error ?? "Opnieuw analyseren mislukt";
225
+ setReparseError(msg);
226
+ toast.error(msg);
227
+ }
228
+ },
229
+ onError: (e) => {
230
+ const msg = e?.message ?? "Opnieuw analyseren mislukt";
231
+ setReparseError(msg);
232
+ toast.error(msg);
233
+ },
234
+ });
235
+ const didAgentCheck = useRef(false);
236
+ useEffect(() => {
237
+ if (didAgentCheck.current)
238
+ return;
239
+ didAgentCheck.current = true;
240
+ agentRefreshMut.mutate();
241
+ const channel = supabase
242
+ .channel("ai-agent-changes")
243
+ .on("postgres_changes", { event: "*", schema: "public", table: "ai_module_bindings" }, () => agentRefreshMut.mutate())
244
+ .on("postgres_changes", { event: "*", schema: "public", table: "ai_models" }, () => agentRefreshMut.mutate())
245
+ .on("postgres_changes", { event: "*", schema: "public", table: "ai_credentials" }, () => agentRefreshMut.mutate())
246
+ .subscribe();
247
+ const t = setInterval(() => agentRefreshMut.mutate(), 120000);
248
+ return () => { clearInterval(t); void supabase.removeChannel(channel); };
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ }, []);
123
251
  const items = Array.isArray(data?.items) ? data.items : [];
124
252
  const template = Array.isArray(templateData?.items) ? templateData.items : [];
125
253
  const customers = Array.isArray(customersData?.items) ? customersData.items : [];
126
254
  const products = Array.isArray(productsData?.items) ? productsData.items : [];
255
+ // Rule Engine bepaalt of doorverkopen zonder voorraad mag
256
+ const allowBackorder = productsData?.allowBackorder === true;
127
257
  const plantTypes = Array.isArray(plantData?.items) ? plantData.items : [];
128
258
  const suppliers = Array.isArray(supplierData?.items) ? supplierData.items : [];
129
259
  const carriers = Array.isArray(carriersData?.items) ? carriersData.items : [];
@@ -135,7 +265,7 @@ export function MailboxProposalsPage() {
135
265
  const customerNameFor = (p) => {
136
266
  const fvs = Array.isArray(p.mailbox_proposal_field_values) ? p.mailbox_proposal_field_values : [];
137
267
  const fv = fvs.find((v) => v.field_key === "customer");
138
- const id = fv?.value ?? p.matched_customer_id ?? null;
268
+ const id = resolveCustomerId(fv?.value, customers, p.matched_customer_id);
139
269
  if (id) {
140
270
  const c = customers.find((x) => x.id === id);
141
271
  if (c?.company_name)
@@ -143,17 +273,24 @@ export function MailboxProposalsPage() {
143
273
  }
144
274
  return p.mailbox_messages?.from_name || p.mailbox_messages?.from_email || "Onbekend";
145
275
  };
146
- const orderNumberFor = (p) => `MB-${String(p.id).slice(0, 8).toUpperCase()}`;
147
- return (_jsxs("div", { className: "p-6 space-y-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold", children: "Bestelvoorstellen" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Nieuw binnengekomen bestellingen die nog handmatig gecontroleerd moeten worden." })] }), _jsxs(Button, { onClick: handleManualSync, disabled: backfillMut.isPending, variant: "outline", children: [_jsx(RefreshCw, { className: `h-4 w-4 mr-2 ${backfillMut.isPending ? "animate-spin" : ""}` }), "Sync nu"] })] }), isLoading ? (_jsx("div", { className: "text-muted-foreground", children: "Laden..." })) : items.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: "Geen bestellingen te controleren." })) : (_jsx(Card, { children: _jsx(CardContent, { className: "p-0", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Ordernummer" }), _jsx(TableHead, { children: "Klantnaam" }), _jsx(TableHead, { children: "Orderdatum" }), _jsx(TableHead, { children: "Status" }), _jsx(TableHead, { className: "text-right", children: "Orderwaarde" })] }) }), _jsx(TableBody, { children: items.map((p) => {
148
- const fvs = Array.isArray(p.mailbox_proposal_field_values) ? p.mailbox_proposal_field_values : [];
149
- const needsReview = fvs.some((v) => v.needs_review);
150
- const date = p.mailbox_messages?.received_at ?? p.created_at;
151
- return (_jsxs(TableRow, { className: "cursor-pointer hover:bg-muted/50", onClick: () => setActiveId(p.id), children: [_jsx(TableCell, { className: "font-mono text-xs", children: orderNumberFor(p) }), _jsx(TableCell, { className: "font-medium", children: customerNameFor(p) }), _jsx(TableCell, { className: "text-sm text-muted-foreground", children: date ? new Date(date).toLocaleDateString("nl-NL") : "-" }), _jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Badge, { variant: statusVariant(p.status), children: statusLabel(p.status) }), needsReview && (_jsxs(Badge, { variant: "outline", className: "text-xs border-yellow-500 text-yellow-700", children: [_jsx(AlertTriangle, { className: "h-3 w-3 mr-1" }), "Controleren"] }))] }) }), _jsx(TableCell, { className: "text-right text-sm text-muted-foreground", children: "\u2014" })] }, p.id));
152
- }) })] }) }) })), _jsx(Dialog, { open: !!active, onOpenChange: (o) => { if (!o)
153
- setActiveId(null); }, children: _jsx(DialogContent, { className: "max-w-[95vw] w-[95vw] h-[92vh] max-h-[92vh] p-0 flex flex-col overflow-hidden", children: active && (_jsxs(_Fragment, { children: [_jsx(DialogHeader, { className: "px-6 pt-6 pb-3 shrink-0 border-b", children: _jsxs(DialogTitle, { children: [orderNumberFor(active), " \u00B7 ", customerNameFor(active)] }) }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto overscroll-contain p-6", children: _jsx(ProposalEditor, { proposal: active, template: template, customers: customers, products: products, plantTypes: plantTypes, suppliers: suppliers, carriers: carriers, pickupLocations: pickupLocations, onApprove: async (payload) => {
276
+ return (_jsxs("div", { className: "p-6 flex flex-col gap-4 flex-1 min-h-0 overflow-hidden", children: [_jsx(MailboxAuthBanner, { active: activeMailbox }), _jsxs("div", { className: "flex items-center justify-between shrink-0", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold", children: "Bestelvoorstellen" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "Nieuw binnengekomen bestellingen die nog handmatig gecontroleerd moeten worden." })] }), _jsxs("div", { className: "flex items-center gap-3", children: [lastRun?.finished_at && (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["Laatste sync: ", new Date(lastRun.finished_at).toLocaleTimeString("nl-NL"), " \u2014 ", lastRun.processed, " verwerkt", lastRun.errors > 0 ? `, ${lastRun.errors} fouten` : ""] })), _jsxs(Button, { onClick: handleManualSync, disabled: syncMut.isPending, variant: "outline", children: [_jsx(RefreshCw, { className: `h-4 w-4 mr-2 ${syncMut.isPending ? "animate-spin" : ""}` }), "Sync nu"] })] })] }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto overscroll-contain [scrollbar-gutter:stable]", children: isLoading ? (_jsx("div", { className: "text-muted-foreground", children: "Laden..." })) : listError ? (_jsxs("div", { className: "rounded-md border border-destructive/40 bg-destructive/5 p-4 text-sm", children: [_jsx("p", { className: "font-medium text-destructive", children: "Bestellingen konden niet worden opgehaald." }), _jsxs("p", { className: "text-muted-foreground mt-1", children: [listError?.message ?? "Onbekende fout", " \u2014 de lijst is niet leeg, alleen niet geladen."] }), _jsx(Button, { size: "sm", variant: "outline", className: "mt-3", onClick: () => void refetchList(), children: "Opnieuw proberen" })] })) : items.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: "Geen bestellingen te controleren." })) : (_jsx(Card, { children: _jsx(CardContent, { className: "p-0", children: _jsxs(Table, { children: [_jsx(TableHeader, { children: _jsxs(TableRow, { children: [_jsx(TableHead, { children: "Klantnaam" }), _jsx(TableHead, { children: "Orderdatum" }), _jsx(TableHead, { children: "Status" }), _jsx(TableHead, { className: "text-right", children: "Orderwaarde" })] }) }), _jsx(TableBody, { children: items.map((p) => {
277
+ const fvs = Array.isArray(p.mailbox_proposal_field_values) ? p.mailbox_proposal_field_values : [];
278
+ const needsReview = fvs.some((v) => v.needs_review);
279
+ const date = p.mailbox_messages?.received_at ?? p.created_at;
280
+ return (_jsxs(TableRow, { className: "cursor-pointer hover:bg-muted/50", onClick: () => setActiveId(p.id), children: [_jsx(TableCell, { className: "font-medium", children: customerNameFor(p) }), _jsx(TableCell, { className: "text-sm text-muted-foreground", children: date ? new Date(date).toLocaleDateString("nl-NL") : "-" }), _jsx(TableCell, { children: _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Badge, { variant: statusVariant(p.status), children: statusLabel(p.status) }), p.parsed_payload?.ai?.ok === true && (_jsx(Badge, { variant: "outline", className: "text-xs border-sky-500 text-sky-700", children: "AI" })), needsReview && (_jsxs(Badge, { variant: "outline", className: "text-xs border-yellow-500 text-yellow-700", children: [_jsx(AlertTriangle, { className: "h-3 w-3 mr-1" }), "Controleren"] }))] }) }), _jsx(TableCell, { className: "text-right text-sm text-muted-foreground", children: "\u2014" })] }, p.id));
281
+ }) })] }) }) })) }), _jsx(Dialog, { open: !!active, onOpenChange: (o) => { if (!o) {
282
+ setActiveId(null);
283
+ setReparseError(null);
284
+ } }, children: _jsx(DialogContent, { className: "max-w-[95vw] w-[95vw] h-[92vh] max-h-[92vh] p-0 flex flex-col overflow-hidden", children: active && (_jsxs(_Fragment, { children: [_jsxs(DialogHeader, { className: "px-6 pt-6 pb-3 shrink-0 border-b", children: [_jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsx(DialogTitle, { children: customerNameFor(active) }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "mr-8", disabled: reparseMutation.isPending, onClick: () => reparseMutation.mutate(active.id), children: [_jsx(RefreshCw, { className: `h-4 w-4 mr-2 ${reparseMutation.isPending ? "animate-spin" : ""}` }), reparseMutation.isPending ? "Analyseren…" : "Mail opnieuw analyseren"] })] }), reparseError && (_jsxs("div", { className: "mt-2 flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive", children: [_jsx(AlertTriangle, { className: "h-4 w-4 mt-0.5 shrink-0" }), _jsx("span", { children: reparseError })] }))] }), _jsx("div", { className: "flex-1 min-h-0 overflow-hidden p-6", children: _jsx(ProposalEditor, { proposal: active, template: template, customers: customers, products: products, allowBackorder: allowBackorder, plantTypes: plantTypes, suppliers: suppliers, carriers: carriers, pickupLocations: pickupLocations, onApprove: async (payload) => {
154
285
  try {
155
- await fetchApprove({ data: payload });
286
+ const res = await fetchApprove({ data: payload });
287
+ const shortages = Array.isArray(res?.shortages) ? res.shortages : [];
156
288
  toast.success("Bestelling aangemaakt — karindeling wordt automatisch aangemaakt in de Verkoop-module");
289
+ if (shortages.length > 0) {
290
+ toast.warning(`Tekorten (levering zonder voorraad staat aan): ${shortages
291
+ .map((s) => `${s.product ?? s.productId}: besteld ${s.wanted}, beschikbaar ${s.available}, tekort ${s.missing}`)
292
+ .join(" · ")}`);
293
+ }
157
294
  qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
158
295
  setActiveId(null);
159
296
  }
@@ -175,7 +312,7 @@ export function MailboxProposalsPage() {
175
312
  function ReadOnlyField({ label, value }) {
176
313
  return (_jsxs("div", { className: "space-y-0.5", children: [_jsx("div", { className: "text-[11px] uppercase tracking-wide text-muted-foreground", children: label }), _jsx("div", { className: "text-sm", children: value && String(value).trim() !== "" ? value : _jsx("span", { className: "text-muted-foreground", children: "\u2014" }) })] }));
177
314
  }
178
- function ProposalEditor({ proposal, template, customers, products, plantTypes, suppliers, carriers, pickupLocations, onApprove, onReject, }) {
315
+ function ProposalEditor({ proposal, template, customers, products, allowBackorder, plantTypes, suppliers, carriers, pickupLocations, onApprove, onReject, }) {
179
316
  const msg = proposal.mailbox_messages ?? {};
180
317
  const fieldValues = Array.isArray(proposal.mailbox_proposal_field_values)
181
318
  ? proposal.mailbox_proposal_field_values : [];
@@ -191,12 +328,23 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
191
328
  visibleFields.forEach((f) => {
192
329
  init[f.key] = fvMap[f.key]?.value ?? f.default_value ?? null;
193
330
  });
194
- // Vul de auto-herkende klant in als er nog geen waarde uit het template komt
195
- if (!init["customer"] && proposal.matched_customer_id) {
196
- init["customer"] = proposal.matched_customer_id;
197
- }
331
+ // De AI schrijft soms een klantnaam i.p.v. een id — altijd omzetten naar een
332
+ // geldig klant-id, met de automatisch herkende klant als terugval.
333
+ init["customer"] = resolveCustomerId(init["customer"], customers, proposal.matched_customer_id);
198
334
  return init;
199
335
  });
336
+ // Klantenlijst kan later binnenkomen: alsnog omzetten zodra dat kan.
337
+ useEffect(() => {
338
+ setValues((prev) => {
339
+ const resolved = resolveCustomerId(prev["customer"], customers, proposal.matched_customer_id);
340
+ return resolved === prev["customer"] ? prev : { ...prev, customer: resolved };
341
+ });
342
+ }, [customers, proposal.matched_customer_id]);
343
+ const defaultBelading = (p) => p ? {
344
+ plants_per_crate: Math.max(1, Number(p.plants_per_crate ?? 1)),
345
+ crates_per_layer: Math.max(1, Number(p.crates_per_layer ?? 1)),
346
+ layers_per_cart: Math.max(1, Number(p.layers_per_cart ?? 1)),
347
+ } : null;
200
348
  const initialLines = Array.isArray(proposal.mailbox_proposal_lines)
201
349
  ? proposal.mailbox_proposal_lines.map((l) => {
202
350
  const p = products.find((x) => x.id === l.product_id);
@@ -207,14 +355,34 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
207
355
  id: l.id,
208
356
  product_id: l.product_id,
209
357
  raw_product_text: l.raw_product_text ?? "",
358
+ plant_type: l.plant_type ?? null,
210
359
  quantity: normalized || null,
211
360
  unit: l.unit,
361
+ // Geen belading bekend uit de mail → altijd de standaardbelading.
362
+ belading: defaultBelading(p),
212
363
  };
213
364
  })
214
365
  : [];
215
366
  const [lines, setLines] = useState(initialLines.length ? initialLines : [
216
- { product_id: null, raw_product_text: "", quantity: null, unit: "stuks" },
367
+ { product_id: null, raw_product_text: "", quantity: null, unit: "stuks", belading: null },
217
368
  ]);
369
+ // Beschikbare beladingen per gekozen product (zelfde bron als Verkoop-module).
370
+ const fetchBeladingen = useServerFn(listBeladingOptionsForProducts);
371
+ const lineProductIds = useMemo(() => Array.from(new Set(lines.map((l) => l.product_id).filter(Boolean))), [lines]);
372
+ const { data: beladingData, isFetching: loadingBeladingen } = useQuery({
373
+ queryKey: ["mailbox-beladingen", lineProductIds.join(",")],
374
+ enabled: lineProductIds.length > 0,
375
+ queryFn: async () => {
376
+ try {
377
+ return await fetchBeladingen({ data: { productIds: lineProductIds } });
378
+ }
379
+ catch {
380
+ return { byProduct: {} };
381
+ }
382
+ },
383
+ });
384
+ const beladingByProduct = beladingData?.byProduct && typeof beladingData.byProduct === "object"
385
+ ? beladingData.byProduct : {};
218
386
  const customerVal = values["customer"];
219
387
  const deliveryVal = values["delivery_date"];
220
388
  const notesVal = values["notes"];
@@ -242,6 +410,15 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
242
410
  // schedule reset via microtask om setState-in-render te vermijden
243
411
  queueMicrotask(() => setVal("delivery_location", null));
244
412
  }
413
+ // Geen locatie uit de mail/AI? Val terug op de hoofdafleverlocatie van de klant.
414
+ useEffect(() => {
415
+ if (deliveryLocationVal || deliveryLocations.length === 0)
416
+ return;
417
+ const primary = deliveryLocations.find((l) => l.is_primary === true) ?? deliveryLocations[0];
418
+ if (primary?.id)
419
+ setVal("delivery_location", primary.id);
420
+ // eslint-disable-next-line react-hooks/exhaustive-deps
421
+ }, [deliveryLocationVal, deliveryLocations.length, customerVal]);
245
422
  // Auto-vul vervoerder zodra een nieuwe locatie wordt gekozen (één keer per wissel).
246
423
  const lastLocRef = useRef(deliveryLocationVal);
247
424
  useEffect(() => {
@@ -272,57 +449,119 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
272
449
  const selectedCarrier = carriers.find((c) => c.id === carrierVal) ?? null;
273
450
  const carrierDeliveryDays = Math.max(0, Number(selectedCarrier?.delivery_days ?? 0));
274
451
  const pickupLocationVal = values["pickup_location"];
452
+ // ─── Ladingdragers uit de Rule Engine (nooit cachen) ──────────────────────
453
+ const fetchLoadCarriers = useServerFn(listAllowedLoadCarriers);
454
+ const { data: lcData } = useQuery({
455
+ queryKey: ["mailbox-load-carriers"],
456
+ staleTime: 0,
457
+ gcTime: 0,
458
+ refetchOnMount: "always",
459
+ refetchOnWindowFocus: true,
460
+ queryFn: async () => {
461
+ try {
462
+ return await fetchLoadCarriers();
463
+ }
464
+ catch {
465
+ return EMPTY_LOAD_CARRIERS;
466
+ }
467
+ },
468
+ });
469
+ const loadCarrierList = useMemo(() => normalizeAllowedLoadCarriers(lcData), [lcData]);
470
+ const loadCarrierOptions = enabledLoadCarriers(loadCarrierList);
471
+ const loadCarrierVal = values["load_carrier"];
472
+ const loadCarrierBlocked = proposal.parsed_payload?.load_carrier_blocked === true
473
+ ? (proposal.parsed_payload?.load_carrier_detected ?? "onbekend")
474
+ : null;
475
+ // Onbekende of uitgeschakelde drager wissen; niets herkend → standaard van de Engine.
476
+ useEffect(() => {
477
+ if (loadCarrierOptions.length === 0)
478
+ return;
479
+ if (loadCarrierVal && !loadCarrierOptions.some((c) => c.code === loadCarrierVal)) {
480
+ setVal("load_carrier", null);
481
+ return;
482
+ }
483
+ if (!loadCarrierVal) {
484
+ const def = loadCarrierOptions.find((c) => c.code === loadCarrierList.default) ?? loadCarrierOptions[0];
485
+ if (def)
486
+ setVal("load_carrier", def.code);
487
+ }
488
+ // eslint-disable-next-line react-hooks/exhaustive-deps
489
+ }, [loadCarrierVal, loadCarrierOptions.length, loadCarrierList.default]);
490
+ // Geen afhaallocatie uit de mail/AI? Val terug op de hoofd-afhaallocatie (isDefault).
491
+ useEffect(() => {
492
+ if (pickupLocations.length === 0)
493
+ return;
494
+ const known = pickupLocationVal && pickupLocations.some((l) => l.id === pickupLocationVal);
495
+ if (known)
496
+ return;
497
+ const main = pickupLocations.find((l) => l.is_default === true) ?? pickupLocations[0];
498
+ if (main?.id)
499
+ setVal("pickup_location", main.id);
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps
501
+ }, [pickupLocationVal, pickupLocations.length]);
275
502
  const canPickDate = !!customerVal && !!deliveryLocationVal && !!pickupLocationVal && !!carrierVal;
276
503
  const fmtISO = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
277
- const earliestDate = useMemo(() => {
278
- const base = new Date();
279
- base.setHours(0, 0, 0, 0);
280
- base.setDate(base.getDate() + pickDays + carrierDeliveryDays);
281
- return base;
282
- }, [pickDays, carrierDeliveryDays]);
283
- const earliestISO = fmtISO(earliestDate);
504
+ const earliest = useMemo(() => computeEarliestDelivery(selectedCarrier, pickDays), [selectedCarrier, pickDays]);
505
+ const earliestDate = earliest.date;
506
+ const earliestISO = earliest.iso;
284
507
  const earliestLabel = earliestDate.toLocaleDateString("nl-NL", {
285
508
  day: "numeric", month: "long", year: "numeric",
286
509
  });
287
- // Synchroniseer delivery_date wanneer de eerst mogelijke datum verandert.
510
+ // Bij wisselen van vervoerder: bezorgdatum altijd opnieuw bepalen.
511
+ const lastCarrierRef = useRef(carrierVal);
512
+ useEffect(() => {
513
+ if (carrierVal === lastCarrierRef.current)
514
+ return;
515
+ lastCarrierRef.current = carrierVal;
516
+ if (!carrierVal)
517
+ return;
518
+ setVal("delivery_date", earliestISO);
519
+ // eslint-disable-next-line react-hooks/exhaustive-deps
520
+ }, [carrierVal, earliestISO]);
521
+ // Synchroniseer delivery_date wanneer de eerst mogelijke datum verandert of
522
+ // de huidige datum niet geldig is voor de gekozen vervoerder.
288
523
  useEffect(() => {
289
524
  if (!canPickDate)
290
525
  return;
291
- if (!deliveryVal || deliveryVal < earliestISO) {
526
+ if (!deliveryVal || !isValidDeliveryDate(selectedCarrier, deliveryVal, pickDays)) {
292
527
  setVal("delivery_date", earliestISO);
293
528
  }
294
529
  // eslint-disable-next-line react-hooks/exhaustive-deps
295
- }, [canPickDate, earliestISO]);
296
- const handleApprove = () => {
530
+ }, [canPickDate, earliestISO, deliveryVal, carrierVal]);
531
+ const [confirmOpen, setConfirmOpen] = useState(false);
532
+ const [submitting, setSubmitting] = useState(false);
533
+ const [checkedOk, setCheckedOk] = useState(false);
534
+ const buildPayload = () => {
297
535
  const missing = template.filter((f) => f.required && (values[f.key] === null || values[f.key] === undefined || values[f.key] === ""));
298
536
  if (missing.length > 0) {
299
537
  toast.error(`Verplichte velden ontbreken: ${missing.map((m) => m.label).join(", ")}`);
300
- return;
538
+ return null;
301
539
  }
302
540
  if (!deliveryLocationVal) {
303
541
  toast.error("Selecteer een afleverlocatie.");
304
- return;
542
+ return null;
305
543
  }
306
544
  if (!pickupLocationVal) {
307
545
  toast.error("Selecteer een afhaallocatie (eigen locatie / tuin).");
308
- return;
546
+ return null;
309
547
  }
310
548
  const validLines = lines.filter((l) => l.product_id && (l.quantity ?? 0) > 0);
311
549
  if (validLines.length === 0) {
312
550
  toast.error("Voeg minimaal één product toe aan de bestelling.");
313
- return;
551
+ return null;
314
552
  }
315
553
  for (const l of validLines) {
316
554
  const p = products.find((x) => x.id === l.product_id);
317
- const ppc = Math.max(1, Number(p?.plants_per_crate ?? 1));
555
+ const b = l.belading ?? defaultBelading(p);
556
+ const ppc = Math.max(1, Number(b?.plants_per_crate ?? 1));
318
557
  const qty = l.quantity ?? 0;
319
558
  if (qty < ppc || qty % ppc !== 0) {
320
559
  toast.error(`"${p?.product ?? l.raw_product_text}" moet per hele fust besteld worden (minimaal 1 fust = ${ppc} planten).`);
321
- return;
560
+ return null;
322
561
  }
323
562
  }
324
563
  const cust = customers.find((c) => c.id === customerVal);
325
- onApprove({
564
+ return {
326
565
  proposalId: proposal.id,
327
566
  customerId: customerVal ?? null,
328
567
  customerName: cust?.company_name ?? msg.from_name ?? msg.from_email ?? "Onbekend",
@@ -330,17 +569,46 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
330
569
  notes: notesVal || null,
331
570
  lines: validLines,
332
571
  fieldValues: Object.entries(values).map(([field_key, value]) => ({ field_key, value })),
333
- });
572
+ };
573
+ };
574
+ // Stap 1: controleren → bevestigingsdialoog. Pas na bevestigen wordt de
575
+ // bestelling daadwerkelijk aangemaakt in Bestellingen.
576
+ const handleApprove = () => {
577
+ if (!buildPayload())
578
+ return;
579
+ setConfirmOpen(true);
334
580
  };
335
- return (_jsxs("div", { className: "grid grid-cols-2 gap-4", children: [_jsxs(Card, { children: [_jsxs(CardHeader, { children: [_jsx(CardTitle, { className: "text-base", children: msg.subject || "(geen onderwerp)" }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [msg.from_name, " <", msg.from_email, "> \u00B7", " ", msg.received_at ? new Date(msg.received_at).toLocaleString("nl-NL") : ""] })] }), _jsx(CardContent, { children: _jsx("pre", { className: "text-sm whitespace-pre-wrap font-sans max-h-[60vh] overflow-y-auto", children: msg.body_text || "(geen tekst)" }) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Bestelvoorstel" }) }), _jsxs(CardContent, { className: "space-y-4", children: [(() => {
336
- const customerField = visibleFields.find((f) => f.key === "customer");
581
+ const handleConfirm = async () => {
582
+ const payload = buildPayload();
583
+ if (!payload)
584
+ return;
585
+ setSubmitting(true);
586
+ try {
587
+ await onApprove(payload);
588
+ setConfirmOpen(false);
589
+ }
590
+ finally {
591
+ setSubmitting(false);
592
+ }
593
+ };
594
+ return (_jsxs("div", { className: "grid grid-cols-2 gap-4 h-full min-h-0", children: [_jsxs(Card, { className: "flex flex-col min-h-0 overflow-hidden", children: [_jsxs(CardHeader, { className: "shrink-0", children: [_jsx(CardTitle, { className: "text-base", children: msg.subject || "(geen onderwerp)" }), _jsxs("div", { className: "text-xs text-muted-foreground", children: [msg.from_name, " <", msg.from_email, "> \u00B7", " ", msg.received_at ? new Date(msg.received_at).toLocaleString("nl-NL") : ""] })] }), _jsx(CardContent, { className: "flex-1 min-h-0 overflow-y-auto overscroll-contain", children: _jsx("pre", { className: "text-sm whitespace-pre-wrap font-sans", children: msg.body_text || "(geen tekst)" }) })] }), _jsxs(Card, { className: "flex flex-col min-h-0 overflow-hidden", children: [_jsx(CardHeader, { className: "shrink-0", children: _jsx(CardTitle, { className: "text-base", children: "Bestelvoorstel" }) }), _jsxs(CardContent, { className: "space-y-4 flex-1 min-h-0 overflow-y-auto overscroll-contain", children: [(() => {
595
+ // Klantveld staat altijd bovenaan, ook als het template (nog) leeg is.
596
+ const customerField = visibleFields.find((f) => f.key === "customer") ?? {
597
+ id: "customer",
598
+ key: "customer",
599
+ label: "Klant",
600
+ field_type: "customer",
601
+ required: true,
602
+ visible: true,
603
+ };
337
604
  const deliveryDateField = visibleFields.find((f) => f.key === "delivery_date");
338
605
  const otherFields = visibleFields.filter((f) => f.key !== "customer" && f.key !== "delivery_date");
339
606
  const renderField = (field) => (_jsx(DynamicField, { field: field, value: values[field.key], meta: fvMap[field.key], onChange: (v) => setVal(field.key, v), customers: customers, products: products, plantTypes: plantTypes, suppliers: suppliers }, field.key));
340
607
  const payload = (proposal.parsed_payload ?? {});
341
608
  const autoMatched = payload.auto_matched_customer === true;
342
609
  const matchWarning = typeof payload.auto_match_warning === "string" ? payload.auto_match_warning : null;
343
- return (_jsxs(_Fragment, { children: [autoMatched && (_jsxs("div", { className: "rounded-md border border-emerald-200 bg-emerald-50 px-3 py-2 text-xs text-emerald-800", children: ["Klant automatisch herkend op basis van het e-mailadres van de afzender", payload.auto_match_email ? ` (${payload.auto_match_email})` : "", "."] })), matchWarning && (_jsx("div", { className: "rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800", children: matchWarning })), customerField && renderField(customerField), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Locatie" }), _jsx(SearchableSelect, { value: deliveryLocationVal, placeholder: !customerVal
610
+ const ai = (payload.ai ?? null);
611
+ return (_jsxs(_Fragment, { children: [ai && ai.ok === false && (_jsxs("div", { className: "rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800", children: ["AI kon deze mail niet uitlezen \u2014 vul de velden handmatig in.", ai.error ? ` (${ai.error})` : ""] })), matchWarning && (_jsx("div", { className: "rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800", children: matchWarning })), customerField && renderField(customerField), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Locatie" }), _jsx(SearchableSelect, { value: deliveryLocationVal, placeholder: !customerVal
344
612
  ? "Selecteer eerst een klant"
345
613
  : deliveryLocations.length
346
614
  ? "Kies locatie..."
@@ -355,14 +623,14 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
355
623
  label: (l.name || addr || "Locatie") + (l.is_primary ? " ★" : ""),
356
624
  hint: l.name && addr ? addr : undefined,
357
625
  };
358
- }), onChange: (v) => setVal("delivery_location", v) })] }), selectedLocation && (_jsxs("div", { className: "rounded-md border bg-muted/30 p-3 space-y-2", children: [_jsx("div", { className: "text-xs font-medium text-muted-foreground", children: "Adresgegevens" }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(ReadOnlyField, { label: "Straat", value: selectedLocation.street }), _jsx(ReadOnlyField, { label: "Huisnummer", value: selectedLocation.house_number }), _jsx(ReadOnlyField, { label: "Postcode", value: selectedLocation.postal_code }), _jsx(ReadOnlyField, { label: "Plaats", value: selectedLocation.city }), _jsx(ReadOnlyField, { label: "Provincie", value: selectedLocation.province }), _jsx(ReadOnlyField, { label: "Land", value: selectedLocation.country })] })] })), _jsxs("div", { className: "space-y-1", children: [_jsxs(Label, { className: "flex items-center gap-2", children: ["Afhaallocatie / Tuin ", _jsx("span", { className: "text-destructive", children: "*" })] }), _jsx(SearchableSelect, { value: pickupLocationVal, placeholder: pickupLocations.length ? "Kies eigen locatie..." : "Geen eigen locaties bekend", clearLabel: "\u2014 Geen \u2014", allowClear: false, disabled: pickupLocations.length === 0, options: pickupLocations.map((l) => ({ value: l.id, label: l.name })), onChange: (v) => setVal("pickup_location", v) }), _jsx("div", { className: "text-xs text-muted-foreground", children: "Locatie van waaruit de bestelling wordt geleverd of opgehaald." })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Vervoerder" }), _jsx(SearchableSelect, { value: carrierVal, placeholder: !customerVal
626
+ }), onChange: (v) => setVal("delivery_location", v) })] }), selectedLocation && (_jsxs("div", { className: "rounded-md border bg-muted/30 p-3 space-y-2", children: [_jsx("div", { className: "text-xs font-medium text-muted-foreground", children: "Adresgegevens" }), _jsxs("div", { className: "grid grid-cols-2 gap-2", children: [_jsx(ReadOnlyField, { label: "Straat", value: selectedLocation.street }), _jsx(ReadOnlyField, { label: "Huisnummer", value: selectedLocation.house_number }), _jsx(ReadOnlyField, { label: "Postcode", value: selectedLocation.postal_code }), _jsx(ReadOnlyField, { label: "Plaats", value: selectedLocation.city }), _jsx(ReadOnlyField, { label: "Provincie", value: selectedLocation.province }), _jsx(ReadOnlyField, { label: "Land", value: selectedLocation.country })] })] })), _jsxs("div", { className: "space-y-1", children: [_jsxs(Label, { className: "flex items-center gap-2", children: ["Afhaallocatie / Tuin ", _jsx("span", { className: "text-destructive", children: "*" })] }), _jsx(SearchableSelect, { value: pickupLocationVal, placeholder: pickupLocations.length ? "Kies eigen locatie..." : "Geen eigen locaties bekend", clearLabel: "\u2014 Geen \u2014", allowClear: false, disabled: pickupLocations.length === 0, options: pickupLocations.map((l) => ({ value: l.id, label: l.name + (l.is_default ? " ★" : "") })), onChange: (v) => setVal("pickup_location", v) }), _jsx("div", { className: "text-xs text-muted-foreground", children: "Locatie van waaruit de bestelling wordt geleverd of opgehaald." })] }), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Vervoerder" }), _jsx(SearchableSelect, { value: carrierVal, placeholder: !customerVal
359
627
  ? "Selecteer eerst een klant"
360
628
  : !deliveryLocationVal
361
629
  ? "Selecteer eerst een locatie"
362
630
  : "Kies vervoerder...", clearLabel: "\u2014 Geen \u2014", disabled: !deliveryLocationVal, options: carriers.map((c) => ({
363
631
  value: c.id,
364
632
  label: c.name + (c.is_default ? " (standaard)" : ""),
365
- })), onChange: (v) => setVal("carrier", v) }), selectedLocation?.carrier_id && carrierVal !== selectedLocation.carrier_id && (_jsx("div", { className: "text-xs text-muted-foreground", children: "Wijkt af van standaard vervoerder van deze locatie \u2014 geldt alleen voor deze bestelling." }))] }), _jsxs("div", { className: "space-y-1", children: [_jsxs(Label, { className: "flex items-center gap-2", children: [deliveryDateField?.label ?? "Verzenddatum", deliveryDateField?.required && _jsx("span", { className: "text-destructive", children: "*" })] }), _jsx(Input, { type: "date", value: canPickDate ? (deliveryVal ?? "") : "", min: canPickDate ? earliestISO : undefined, disabled: !canPickDate, onChange: (e) => {
633
+ })), onChange: (v) => setVal("carrier", v) }), selectedLocation?.carrier_id && carrierVal !== selectedLocation.carrier_id && (_jsx("div", { className: "text-xs text-muted-foreground", children: "Wijkt af van standaard vervoerder van deze locatie \u2014 geldt alleen voor deze bestelling." }))] }), _jsxs("div", { className: "space-y-1", children: [_jsxs(Label, { className: "flex items-center gap-2", children: ["Bezorgdatum", deliveryDateField?.required && _jsx("span", { className: "text-destructive", children: "*" })] }), _jsx(Input, { type: "date", value: canPickDate ? (deliveryVal ?? "") : "", min: canPickDate ? earliestISO : undefined, disabled: !canPickDate, onChange: (e) => {
366
634
  const v = e.target.value || null;
367
635
  if (v && v < earliestISO) {
368
636
  toast.error(`Eerst mogelijke leverdatum is ${earliestLabel}.`);
@@ -370,10 +638,39 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
370
638
  return;
371
639
  }
372
640
  setVal("delivery_date", v);
373
- } }), !canPickDate ? (_jsx("div", { className: "text-xs text-muted-foreground", children: "Selecteer eerst klant, afleverlocatie, afhaallocatie en vervoerder." })) : (_jsxs("div", { className: "text-xs text-muted-foreground", children: ["Eerst beschikbare leverdatum is ", earliestLabel, ", op basis van orderpicktijd (", pickDays, "d) en levertijd vervoerder (", carrierDeliveryDays, "d)."] }))] }), otherFields.map(renderField)] }));
374
- })(), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "Bestelregels" }), _jsxs("div", { className: "grid grid-cols-12 gap-2 text-xs text-muted-foreground px-1", children: [_jsx("div", { className: "col-span-5", children: "Product" }), _jsx("div", { className: "col-span-2", children: "Planten" }), _jsx("div", { className: "col-span-2", children: "Fusten" }), _jsx("div", { className: "col-span-2", children: "Eenheid" }), _jsx("div", { className: "col-span-1" })] }), lines.map((line, idx) => {
641
+ } }), !canPickDate ? (_jsx("div", { className: "text-xs text-muted-foreground", children: "Selecteer eerst klant, afleverlocatie, afhaallocatie en vervoerder." })) : (_jsxs("div", { className: "text-xs text-muted-foreground", children: ["Eerst beschikbare leverdatum is ", earliestLabel, earliest.signupAt
642
+ ? `, o.b.v. het eerstvolgende aanmeldmoment bij de vervoerder (${earliest.signupAt.toLocaleString("nl-NL", { weekday: "long", day: "numeric", month: "long", hour: "2-digit", minute: "2-digit" })})`
643
+ : `, op basis van orderpicktijd (${pickDays}d) en levertijd vervoerder (${carrierDeliveryDays}d)`, "."] }))] }), otherFields.map(renderField)] }));
644
+ })(), _jsxs("div", { className: "space-y-2", children: [_jsx(Label, { children: "Bestelregels" }), _jsxs("div", { className: "grid grid-cols-12 gap-2 text-xs text-muted-foreground px-1", children: [_jsx("div", { className: "col-span-4", children: "Product" }), _jsx("div", { className: "col-span-3", children: "Belading" }), _jsx("div", { className: "col-span-4", children: "Fusten" }), _jsx("div", { className: "col-span-1" })] }), lines.map((line, idx) => {
375
645
  const product = line.product_id ? products.find((p) => p.id === line.product_id) : null;
376
- const ppc = Math.max(1, Number(product?.plants_per_crate ?? 1));
646
+ const stdBelading = defaultBelading(product);
647
+ const available = (line.product_id ? (Array.isArray(beladingByProduct[line.product_id]) ? beladingByProduct[line.product_id] : []) : []).map((b) => ({
648
+ plants_per_crate: Math.max(1, Number(b.plants_per_crate ?? 1)),
649
+ crates_per_layer: Math.max(1, Number(b.crates_per_layer ?? 1)),
650
+ layers_per_cart: Math.max(1, Number(b.layers_per_cart ?? 1)),
651
+ crate_code: b.crate_code ?? null,
652
+ crate_name: b.crate_name ?? null,
653
+ carrier_code: b.carrier_code ?? null,
654
+ carrier_name: b.carrier_name ?? null,
655
+ })).filter((b) => beladingAllowedByEngine(b, loadCarrierOptions));
656
+ // Standaardbelading: gebruik de variant mét fust-/kartype als die bestaat.
657
+ const sameDims = (a, b) => a.plants_per_crate === b.plants_per_crate &&
658
+ a.crates_per_layer === b.crates_per_layer &&
659
+ a.layers_per_cart === b.layers_per_cart;
660
+ const richDefault = stdBelading ? available.find((b) => sameDims(b, stdBelading)) ?? null : null;
661
+ const effDefault = richDefault ?? stdBelading;
662
+ const optionMap = new Map();
663
+ for (const b of available)
664
+ optionMap.set(beladingKey(b), b);
665
+ if (effDefault)
666
+ optionMap.set(beladingKey(effDefault), effDefault);
667
+ const defaultKey = effDefault ? beladingKey(effDefault) : null;
668
+ // Geen belading in de mail → altijd de standaardbelading van het product.
669
+ const chosenBelading = line.belading ?? effDefault;
670
+ const effBelading = chosenBelading && !optionMap.has(beladingKey(chosenBelading))
671
+ ? (available.find((b) => sameDims(b, chosenBelading)) ?? chosenBelading)
672
+ : chosenBelading;
673
+ const ppc = Math.max(1, Number(effBelading?.plants_per_crate ?? product?.plants_per_crate ?? 1));
377
674
  const plants = line.quantity ?? null;
378
675
  const cratesExact = plants !== null ? plants / ppc : null;
379
676
  const cratesDisplay = cratesExact === null ? "" : String(Math.ceil(cratesExact));
@@ -382,46 +679,70 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
382
679
  e.preventDefault();
383
680
  }
384
681
  };
385
- return (_jsxs("div", { className: "grid grid-cols-12 gap-2 items-start", children: [_jsx("div", { className: "col-span-5", children: _jsx(SearchableSelect, { value: line.product_id, placeholder: "Kies product...", clearLabel: "\u2014 Handmatig \u2014", options: products.map((p) => ({
386
- value: p.id,
387
- label: p.product,
388
- hint: `${p.unit ?? ""}${p.available_stock != null ? ` · voorraad: ${p.available_stock}` : ""}`.trim(),
389
- })), onChange: (v) => {
390
- const next = [...lines];
391
- if (v === null)
392
- next[idx] = { ...next[idx], product_id: null };
393
- else {
394
- const p = products.find((x) => x.id === v);
395
- const newPpc = Math.max(1, Number(p?.plants_per_crate ?? 1));
396
- const currentQ = Number(next[idx].quantity ?? 0);
397
- const normalizedQ = currentQ > 0 ? Math.ceil(currentQ / newPpc) * newPpc : newPpc;
682
+ return (_jsxs("div", { className: "grid grid-cols-12 gap-2 items-start", children: [_jsxs("div", { className: "col-span-4", children: [_jsx(SearchableSelect, { value: line.product_id, placeholder: "Kies product...", clearLabel: "\u2014 Handmatig \u2014", options: products.map((p) => {
683
+ const stock = Number(p.available_stock ?? 0);
684
+ const out = stock <= 0;
685
+ return {
686
+ value: p.id,
687
+ label: p.product,
688
+ hint: `${p.unit ?? ""}${p.available_stock != null ? ` · voorraad: ${p.available_stock}` : ""}${out ? (allowBackorder ? " · niet op voorraad (doorverkoop toegestaan)" : " · niet op voorraad") : ""}`.trim(),
689
+ disabled: out && !allowBackorder && p.id !== line.product_id,
690
+ };
691
+ }), onChange: (v) => {
692
+ const next = [...lines];
693
+ if (v === null)
694
+ next[idx] = { ...next[idx], product_id: null, belading: null };
695
+ else {
696
+ const p = products.find((x) => x.id === v);
697
+ const b = defaultBelading(p);
698
+ const newPpc = Math.max(1, Number(b?.plants_per_crate ?? 1));
699
+ const currentQ = Number(next[idx].quantity ?? 0);
700
+ const normalizedQ = currentQ > 0 ? Math.ceil(currentQ / newPpc) * newPpc : newPpc;
701
+ next[idx] = {
702
+ ...next[idx], product_id: v,
703
+ raw_product_text: p?.product ?? next[idx].raw_product_text,
704
+ unit: next[idx].unit ?? p?.unit ?? "stuks",
705
+ quantity: normalizedQ,
706
+ belading: b,
707
+ };
708
+ }
709
+ setLines(next);
710
+ } }), (line.plant_type || line.raw_product_text) && (_jsx("div", { className: "mt-1 truncate text-[11px] text-muted-foreground", children: line.plant_type ? `Plantsoort: ${line.plant_type}` : `Uit mail: ${line.raw_product_text}` }))] }), _jsxs("div", { className: "col-span-3", children: [_jsx(SearchableSelect, { value: effBelading ? beladingKey(effBelading) : null, placeholder: !line.product_id ? "Kies eerst product"
711
+ : loadingBeladingen ? "Beladingen laden..."
712
+ : "Kies belading...", disabled: !line.product_id, allowClear: false, emptyLabel: "Geen belading gekoppeld aan dit product", options: Array.from(optionMap.entries()).map(([key, b]) => ({
713
+ value: key,
714
+ label: beladingLabel(b),
715
+ hint: (key === defaultKey ? "Standaard van product · " : "") +
716
+ beladingHint(b),
717
+ })), onChange: (v) => {
718
+ if (!v)
719
+ return;
720
+ const b = optionMap.get(v);
721
+ if (!b)
722
+ return;
723
+ const next = [...lines];
724
+ const crates = Math.max(1, Math.ceil(Number(next[idx].quantity ?? 0) / ppc));
398
725
  next[idx] = {
399
- ...next[idx], product_id: v,
400
- raw_product_text: p?.product ?? next[idx].raw_product_text,
401
- unit: next[idx].unit ?? p?.unit ?? "stuks",
402
- quantity: normalizedQ,
726
+ ...next[idx],
727
+ belading: b,
728
+ // Fusten behouden, planten herrekenen op de nieuwe belading.
729
+ quantity: crates * b.plants_per_crate,
403
730
  };
404
- }
405
- setLines(next);
406
- } }) }), _jsx(Input, { className: "col-span-2", type: "number", min: ppc, step: ppc, inputMode: "numeric", onKeyDown: blockDecimal, value: plants ?? "", onChange: (e) => {
407
- const next = [...lines];
408
- const raw = e.target.value.replace(/[.,]/g, "");
409
- const v = raw ? Number(raw) : null;
410
- const rounded = v === null ? null : Math.max(1, Math.ceil(v / ppc)) * ppc;
411
- next[idx] = { ...next[idx], quantity: rounded };
412
- setLines(next);
413
- }, placeholder: "Planten", title: `Verkoop per hele fust van ${ppc} planten` }), _jsx(Input, { className: "col-span-2", type: "number", min: 1, step: 1, inputMode: "numeric", onKeyDown: blockDecimal, value: cratesDisplay, onChange: (e) => {
731
+ setLines(next);
732
+ } }), effBelading && (_jsxs("div", { className: "mt-1 text-xs text-muted-foreground", children: [plants ?? 0, " planten", defaultKey && beladingKey(effBelading) !== defaultKey ? " · afwijkend van standaard" : ""] }))] }), _jsx(Input, { className: "col-span-4", type: "number", min: 1, step: 1, inputMode: "numeric", onKeyDown: blockDecimal, value: cratesDisplay, onChange: (e) => {
414
733
  const next = [...lines];
415
734
  const raw = e.target.value.replace(/[.,]/g, "");
416
735
  const cr = raw ? Math.max(1, Math.floor(Number(raw))) : null;
417
736
  next[idx] = { ...next[idx], quantity: cr === null ? null : cr * ppc };
418
737
  setLines(next);
419
- }, placeholder: "Fusten", title: `${ppc} planten per fust — verkoop per hele fust` }), _jsx(Input, { className: "col-span-2", value: line.unit ?? "", onChange: (e) => {
420
- const next = [...lines];
421
- next[idx] = { ...next[idx], unit: e.target.value };
422
- setLines(next);
423
- }, placeholder: "Eenheid" }), _jsx(Button, { variant: "ghost", size: "icon", className: "col-span-1", onClick: () => setLines(lines.filter((_, i) => i !== idx)), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, idx));
424
- }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setLines([...lines, { product_id: null, raw_product_text: "", quantity: 1, unit: "stuks" }]), children: [_jsx(Plus, { className: "h-3 w-3 mr-1" }), " Regel toevoegen"] })] }), _jsxs("div", { className: "flex gap-2 pt-2", children: [_jsxs(Button, { className: "flex-1", onClick: handleApprove, children: [_jsx(Check, { className: "h-4 w-4 mr-1" }), " Goedkeuren"] }), _jsxs(Button, { variant: "destructive", onClick: () => onReject(notesVal || ""), children: [_jsx(X, { className: "h-4 w-4 mr-1" }), " Afwijzen"] })] })] })] })] }));
738
+ }, placeholder: "Fusten", title: `${ppc} planten per fust — verkoop per hele fust` }), _jsx(Button, { variant: "ghost", size: "icon", className: "col-span-1", onClick: () => setLines(lines.filter((_, i) => i !== idx)), children: _jsx(Trash2, { className: "h-4 w-4" }) })] }, idx));
739
+ }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => setLines([...lines, { product_id: null, raw_product_text: "", quantity: 1, unit: "stuks", belading: null }]), children: [_jsx(Plus, { className: "h-3 w-3 mr-1" }), " Regel toevoegen"] })] }), _jsxs("div", { className: "flex gap-2 pt-2", children: [_jsxs(Button, { className: "flex-1", onClick: handleApprove, children: [_jsx(Check, { className: "h-4 w-4 mr-1" }), " Controleren & bevestigen"] }), _jsxs(Button, { variant: "destructive", onClick: () => onReject(notesVal || ""), children: [_jsx(X, { className: "h-4 w-4 mr-1" }), " Afwijzen"] })] }), _jsx(Dialog, { open: confirmOpen, onOpenChange: (o) => {
740
+ if (submitting)
741
+ return;
742
+ setConfirmOpen(o);
743
+ if (!o)
744
+ setCheckedOk(false);
745
+ }, children: _jsxs(DialogContent, { className: "max-w-lg", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Bestelling bevestigen" }) }), _jsxs("div", { className: "space-y-3 text-sm", children: [_jsx("p", { className: "text-muted-foreground", children: "De bestelling wordt pas aangemaakt in Bestellingen nadat je hem hebt gecontroleerd en bevestigd." }), _jsxs("div", { className: "rounded-md border p-3 space-y-1", children: [_jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Klant:" }), " ", customers.find((c) => c.id === customerVal)?.company_name ?? "-"] }), _jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Bezorgdatum:" }), " ", deliveryVal || "-"] }), _jsxs("div", { children: [_jsx("span", { className: "text-muted-foreground", children: "Bestelregels:" }), " ", lines.filter((l) => l.product_id && (l.quantity ?? 0) > 0).length] })] }), _jsxs("label", { className: "flex items-start gap-2 cursor-pointer", children: [_jsx("input", { type: "checkbox", className: "mt-1", checked: checkedOk, onChange: (e) => setCheckedOk(e.target.checked) }), _jsx("span", { children: "Ik heb deze bestelling gecontroleerd en de gegevens kloppen." })] })] }), _jsxs("div", { className: "flex gap-2 pt-2", children: [_jsxs(Button, { className: "flex-1", disabled: !checkedOk || submitting, onClick: handleConfirm, children: [_jsx(Check, { className: "h-4 w-4 mr-1" }), submitting ? "Bezig…" : "Bevestigen en bestelling aanmaken"] }), _jsx(Button, { variant: "outline", disabled: submitting, onClick: () => setConfirmOpen(false), children: "Annuleren" })] })] }) })] })] })] }));
425
746
  }
426
747
  function DynamicField({ field, value, meta, onChange, customers, products, plantTypes, suppliers, }) {
427
748
  const reviewClass = meta?.needs_review ? "border-yellow-500 ring-1 ring-yellow-500/40 rounded-md p-2" : "";