@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.
- package/dist-lib/components/MailboxAuthBanner.d.ts +16 -0
- package/dist-lib/components/MailboxAuthBanner.d.ts.map +1 -0
- package/dist-lib/components/MailboxAuthBanner.js +14 -0
- package/dist-lib/components/MailboxProcessedPage.d.ts.map +1 -1
- package/dist-lib/components/MailboxProcessedPage.js +10 -2
- package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
- package/dist-lib/components/MailboxProposalsPage.js +425 -104
- package/dist-lib/components/SearchableSelect.d.ts +1 -0
- package/dist-lib/components/SearchableSelect.d.ts.map +1 -1
- package/dist-lib/components/SearchableSelect.js +2 -1
- package/dist-lib/components/settings/MailboxAiPromptCard.d.ts +2 -0
- package/dist-lib/components/settings/MailboxAiPromptCard.d.ts.map +1 -0
- package/dist-lib/components/settings/MailboxAiPromptCard.js +39 -0
- package/dist-lib/index.d.ts.map +1 -1
- package/dist-lib/index.js +2 -0
- package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/auth-attacher.js +5 -1
- package/dist-lib/integrations/supabase/auth-middleware.d.ts +2 -11202
- package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/auth-middleware.js +8 -7
- package/dist-lib/integrations/supabase/client.d.ts +16514 -7163
- package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/client.server.d.ts +16516 -7164
- package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.d.ts +16768 -7202
- package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.js +59 -8
- package/dist-lib/lib/ai-config.server.d.ts +46 -0
- package/dist-lib/lib/ai-config.server.d.ts.map +1 -0
- package/dist-lib/lib/ai-config.server.js +99 -0
- package/dist-lib/lib/delivery-date.d.ts +37 -0
- package/dist-lib/lib/delivery-date.d.ts.map +1 -0
- package/dist-lib/lib/delivery-date.js +96 -0
- package/dist-lib/lib/load-carriers.d.ts +26 -0
- package/dist-lib/lib/load-carriers.d.ts.map +1 -0
- package/dist-lib/lib/load-carriers.js +73 -0
- package/dist-lib/lib/mailbox-ai-prompt.d.ts +9 -0
- package/dist-lib/lib/mailbox-ai-prompt.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-ai-prompt.js +30 -0
- package/dist-lib/lib/mailbox-ai-settings.functions.d.ts +23 -0
- package/dist-lib/lib/mailbox-ai-settings.functions.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-ai-settings.functions.js +58 -0
- package/dist-lib/lib/mailbox-ai.server.d.ts +49 -4
- package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
- package/dist-lib/lib/mailbox-ai.server.js +284 -6
- package/dist-lib/lib/mailbox-body.d.ts +7 -0
- package/dist-lib/lib/mailbox-body.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-body.js +38 -0
- package/dist-lib/lib/mailbox-ingest.server.d.ts +76 -0
- package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-ingest.server.js +863 -0
- package/dist-lib/lib/mailbox-template.functions.d.ts +7 -67213
- package/dist-lib/lib/mailbox-template.functions.d.ts.map +1 -1
- package/dist-lib/lib/mailbox.functions.d.ts +107 -190506
- package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
- package/dist-lib/lib/mailbox.functions.js +544 -270
- 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 {
|
|
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
|
|
25
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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 =
|
|
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
|
-
},
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
const t = setInterval(() =>
|
|
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
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
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
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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: [
|
|
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,
|
|
374
|
-
|
|
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
|
|
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: [
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
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],
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
quantity:
|
|
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
|
-
|
|
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(
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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" : "";
|