@flowselections/mailbox-orders 1.0.9 → 1.0.11
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 +16 -0
- package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
- package/dist-lib/components/MailboxProposalsPage.js +520 -108
- 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 +16689 -7180
- package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/client.server.d.ts +16691 -7181
- package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.d.ts +16944 -7220
- package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
- package/dist-lib/integrations/supabase/types.js +63 -1
- package/dist-lib/lib/ai-config.server.d.ts +60 -0
- package/dist-lib/lib/ai-config.server.d.ts.map +1 -0
- package/dist-lib/lib/ai-config.server.js +112 -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 +56 -4
- package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
- package/dist-lib/lib/mailbox-ai.server.js +329 -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 +89 -0
- package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -0
- package/dist-lib/lib/mailbox-ingest.server.js +928 -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 +130 -190511
- package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
- package/dist-lib/lib/mailbox.functions.js +641 -274
- package/package.json +4 -3
- package/dist-lib/components/CartAssignmentWizard.d.ts +0 -6
- package/dist-lib/components/CartAssignmentWizard.d.ts.map +0 -1
- package/dist-lib/components/CartAssignmentWizard.js +0 -199
- package/dist-lib/components/MailboxInboxPage.d.ts +0 -2
- package/dist-lib/components/MailboxInboxPage.d.ts.map +0 -1
- package/dist-lib/components/MailboxInboxPage.js +0 -120
- package/dist-lib/components/settings/ImapAccountsCard.d.ts +0 -2
- package/dist-lib/components/settings/ImapAccountsCard.d.ts.map +0 -1
- package/dist-lib/components/settings/ImapAccountsCard.js +0 -136
- package/dist-lib/components/settings/ImapProfilesCard.d.ts +0 -2
- package/dist-lib/components/settings/ImapProfilesCard.d.ts.map +0 -1
- package/dist-lib/components/settings/ImapProfilesCard.js +0 -101
- package/dist-lib/components/settings/MailboxSettingsCard.d.ts +0 -2
- package/dist-lib/components/settings/MailboxSettingsCard.d.ts.map +0 -1
- package/dist-lib/components/settings/MailboxSettingsCard.js +0 -85
- package/dist-lib/lib/imap.functions.d.ts +0 -32852
- package/dist-lib/lib/imap.functions.d.ts.map +0 -1
- package/dist-lib/lib/imap.functions.js +0 -235
- package/dist-lib/lib/mailbox-auto.server.d.ts +0 -9
- package/dist-lib/lib/mailbox-auto.server.d.ts.map +0 -1
- package/dist-lib/lib/mailbox-auto.server.js +0 -198
|
@@ -4,10 +4,74 @@ 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, createCustomerFromGroup, 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
|
+
/** Alleen een echte lokale klant kan aan een bestelling worden gekoppeld. */
|
|
42
|
+
const isLocalCustomerId = (v) => typeof v === "string" && UUID_RE.test(v);
|
|
43
|
+
const providerLabel = (p) => p === "local" ? "Lokaal" : p === "twinfield" ? "Twinfield" : p === "floriday" ? "Floriday" : p;
|
|
44
|
+
const customerHint = (c) => [c.email, c.city, (c.providers ?? []).map(providerLabel).join(" + ")]
|
|
45
|
+
.filter(Boolean).join(" · ");
|
|
46
|
+
/**
|
|
47
|
+
* Zet een klantwaarde (id óf door AI geschreven klantnaam) om naar een geldige
|
|
48
|
+
* keuze uit de klantenlijst. Lokale klanten hebben voorrang.
|
|
49
|
+
*/
|
|
50
|
+
function resolveCustomerId(raw, customers, fallbackId) {
|
|
51
|
+
const all = Array.isArray(customers) ? customers : [];
|
|
52
|
+
const list = [...all.filter((c) => c.is_local !== false), ...all.filter((c) => c.is_local === false)];
|
|
53
|
+
if (typeof raw === "string" && raw.trim()) {
|
|
54
|
+
const v = raw.trim();
|
|
55
|
+
if (UUID_RE.test(v))
|
|
56
|
+
return v;
|
|
57
|
+
if (v.startsWith("grp:") && list.some((c) => c.id === v))
|
|
58
|
+
return v;
|
|
59
|
+
const norm = v.toLowerCase();
|
|
60
|
+
const byName = list.find((c) => (c.company_name ?? "").trim().toLowerCase() === norm);
|
|
61
|
+
if (byName)
|
|
62
|
+
return byName.id;
|
|
63
|
+
const byEmail = list.find((c) => (c.email ?? "").trim().toLowerCase() === norm);
|
|
64
|
+
if (byEmail)
|
|
65
|
+
return byEmail.id;
|
|
66
|
+
const partial = list.find((c) => {
|
|
67
|
+
const n = (c.company_name ?? "").trim().toLowerCase();
|
|
68
|
+
return n.length > 2 && (n.includes(norm) || norm.includes(n));
|
|
69
|
+
});
|
|
70
|
+
if (partial)
|
|
71
|
+
return partial.id;
|
|
72
|
+
}
|
|
73
|
+
return fallbackId ?? null;
|
|
74
|
+
}
|
|
11
75
|
export function MailboxProposalsPage() {
|
|
12
76
|
const qc = useQueryClient();
|
|
13
77
|
const fetchList = useServerFn(listProposals);
|
|
@@ -21,8 +85,8 @@ export function MailboxProposalsPage() {
|
|
|
21
85
|
const fetchCarriers = useServerFn(listCarriersForPicker);
|
|
22
86
|
const fetchPickupLocations = useServerFn(listPickupLocations);
|
|
23
87
|
const fetchActive = useServerFn(getActiveOrdersMailbox);
|
|
24
|
-
const
|
|
25
|
-
const
|
|
88
|
+
const fetchSync = useServerFn(syncMailboxNow);
|
|
89
|
+
const fetchLastRun = useServerFn(getLastIngestRun);
|
|
26
90
|
// Server fns met requireSupabaseAuth gooien een raw Response bij een stale
|
|
27
91
|
// bearer-token. Die bubbelt op als "[object Response]" en blankt het scherm.
|
|
28
92
|
// Vang elke fout en geef een lege fallback terug — UI blijft bruikbaar.
|
|
@@ -37,9 +101,15 @@ export function MailboxProposalsPage() {
|
|
|
37
101
|
// Bestelvoorstellen toont uitsluitend nieuwe, nog niet gecontroleerde
|
|
38
102
|
// bestellingen ("Te controleren"). Goedgekeurd/afgewezen verschijnt in
|
|
39
103
|
// de module Verwerkt.
|
|
40
|
-
const { data, isLoading } = useQuery({
|
|
104
|
+
const { data, isLoading, error: listError, refetch: refetchList } = useQuery({
|
|
41
105
|
queryKey: ["mailbox-proposals", "pending"],
|
|
42
|
-
|
|
106
|
+
// Bewust NIET stilzwijgend opvangen: als de server tijdelijk onbereikbaar is
|
|
107
|
+
// moet de gebruiker dat zien in plaats van een lege lijst ("geen mailtjes").
|
|
108
|
+
queryFn: () => fetchList({ data: { status: "pending" } }),
|
|
109
|
+
retry: 3,
|
|
110
|
+
retryDelay: (a) => Math.min(1000 * 2 ** a, 8000),
|
|
111
|
+
refetchInterval: 60000,
|
|
112
|
+
refetchOnWindowFocus: true,
|
|
43
113
|
});
|
|
44
114
|
const { data: templateData } = useQuery({
|
|
45
115
|
queryKey: ["mailbox-template-fields"],
|
|
@@ -51,7 +121,6 @@ export function MailboxProposalsPage() {
|
|
|
51
121
|
const { data: supplierData } = useQuery({ queryKey: ["mailbox-suppliers"], queryFn: () => safe(() => fetchSuppliers()) });
|
|
52
122
|
const { data: carriersData } = useQuery({ queryKey: ["mailbox-carriers"], queryFn: () => safe(() => fetchCarriers()) });
|
|
53
123
|
const { data: pickupLocData } = useQuery({ queryKey: ["mailbox-pickup-locations"], queryFn: () => safe(() => fetchPickupLocations()) });
|
|
54
|
-
// Realtime ingestie + backfill — voorheen in de Inbox-pagina.
|
|
55
124
|
const { data: activeMailbox } = useQuery({
|
|
56
125
|
queryKey: ["mailbox-active-account"],
|
|
57
126
|
queryFn: () => fetchActive(),
|
|
@@ -59,23 +128,27 @@ export function MailboxProposalsPage() {
|
|
|
59
128
|
retry: 1,
|
|
60
129
|
});
|
|
61
130
|
const activeMailboxId = activeMailbox?.connected ? activeMailbox.id : null;
|
|
62
|
-
|
|
63
|
-
|
|
131
|
+
// Ingest wordt server-side gedreven (pg_cron, elke minuut). Deze knoppen /
|
|
132
|
+
// triggers hier zijn een extra vangnet zodat de UI direct bijwerkt — de
|
|
133
|
+
// pipeline is exact dezelfde als die van cron.
|
|
134
|
+
const syncMut = useMutation({
|
|
135
|
+
mutationFn: async (trigger) => {
|
|
64
136
|
try {
|
|
65
|
-
return await
|
|
137
|
+
return await fetchSync({ data: { trigger } });
|
|
66
138
|
}
|
|
67
|
-
catch {
|
|
68
|
-
return { ok: false, inserted: 0 };
|
|
139
|
+
catch (e) {
|
|
140
|
+
return { ok: false, error: String(e?.message ?? e), inserted: 0, processed: 0, errors: 1 };
|
|
69
141
|
}
|
|
70
142
|
},
|
|
71
143
|
onSuccess: (res) => {
|
|
72
|
-
if (res?.ok && (res.inserted ?? 0) > 0) {
|
|
144
|
+
if (res?.ok && ((res.inserted ?? 0) > 0 || (res.processed ?? 0) > 0)) {
|
|
73
145
|
qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
|
|
146
|
+
qc.invalidateQueries({ queryKey: ["mailbox-last-ingest-run"] });
|
|
74
147
|
}
|
|
75
148
|
},
|
|
76
149
|
});
|
|
77
150
|
const handleManualSync = () => {
|
|
78
|
-
const p =
|
|
151
|
+
const p = syncMut.mutateAsync("manual");
|
|
79
152
|
toast.promise(p, {
|
|
80
153
|
loading: "Mailbox synchroniseren...",
|
|
81
154
|
success: (res) => res?.ok
|
|
@@ -86,6 +159,8 @@ export function MailboxProposalsPage() {
|
|
|
86
159
|
error: "Sync mislukt",
|
|
87
160
|
});
|
|
88
161
|
};
|
|
162
|
+
// Realtime hint: zodra er een nieuw event binnenkomt vragen we de server om
|
|
163
|
+
// het meteen te verwerken (zelfde pipeline). Cron is de garantiestrook.
|
|
89
164
|
useEffect(() => {
|
|
90
165
|
if (!activeMailboxId)
|
|
91
166
|
return;
|
|
@@ -94,36 +169,99 @@ export function MailboxProposalsPage() {
|
|
|
94
169
|
.on("postgres_changes", {
|
|
95
170
|
event: "INSERT", schema: "public", table: "email_auth_events",
|
|
96
171
|
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 */ }
|
|
172
|
+
}, () => {
|
|
173
|
+
syncMut.mutate("realtime");
|
|
109
174
|
})
|
|
110
175
|
.subscribe();
|
|
111
176
|
return () => { void supabase.removeChannel(channel); };
|
|
112
|
-
|
|
177
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
178
|
+
}, [activeMailboxId]);
|
|
179
|
+
// Bij eerste keer openen + elke 5 min extra vangnet naast pg_cron/realtime.
|
|
113
180
|
const didInitialBackfill = useRef(false);
|
|
114
181
|
useEffect(() => {
|
|
115
182
|
if (!activeMailboxId || didInitialBackfill.current)
|
|
116
183
|
return;
|
|
117
184
|
didInitialBackfill.current = true;
|
|
118
|
-
|
|
119
|
-
const t = setInterval(() =>
|
|
185
|
+
syncMut.mutate("manual");
|
|
186
|
+
const t = setInterval(() => syncMut.mutate("manual"), 300000);
|
|
120
187
|
return () => clearInterval(t);
|
|
121
188
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
122
189
|
}, [activeMailboxId]);
|
|
190
|
+
// Laatste ingest-run tonen voor observability.
|
|
191
|
+
const { data: lastRunData } = useQuery({
|
|
192
|
+
queryKey: ["mailbox-last-ingest-run"],
|
|
193
|
+
queryFn: () => safe(() => fetchLastRun()),
|
|
194
|
+
refetchInterval: 60000,
|
|
195
|
+
});
|
|
196
|
+
const lastRun = lastRunData?.run ?? null;
|
|
197
|
+
// AI-agent gewijzigd? Dan worden openstaande voorstellen automatisch opnieuw
|
|
198
|
+
// geanalyseerd met de nieuwste agent. Server-side draait dit ook elke
|
|
199
|
+
// cron-run; hier reageren we direct op wijzigingen in de AI-module.
|
|
200
|
+
const fetchAgentRefresh = useServerFn(refreshProposalsForAgentChange);
|
|
201
|
+
const agentRefreshMut = useMutation({
|
|
202
|
+
mutationFn: async () => {
|
|
203
|
+
try {
|
|
204
|
+
return await fetchAgentRefresh();
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { reparsed: 0 };
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
onSuccess: (res) => {
|
|
211
|
+
if ((res?.reparsed ?? 0) > 0) {
|
|
212
|
+
qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
|
|
213
|
+
toast.success(`${res.reparsed} voorstel(len) opnieuw geanalyseerd met de bijgewerkte AI-agent`);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
// Handmatig één voorstel opnieuw laten analyseren door de AI-agent.
|
|
218
|
+
const fetchReparse = useServerFn(reparseProposalNow);
|
|
219
|
+
const [reparseError, setReparseError] = useState(null);
|
|
220
|
+
const reparseMutation = useMutation({
|
|
221
|
+
mutationFn: async (proposalId) => {
|
|
222
|
+
setReparseError(null);
|
|
223
|
+
return (await fetchReparse({ data: { proposalId } }));
|
|
224
|
+
},
|
|
225
|
+
onSuccess: (res) => {
|
|
226
|
+
if (res?.ok) {
|
|
227
|
+
setReparseError(null);
|
|
228
|
+
qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
|
|
229
|
+
toast.success("E-mail opnieuw geanalyseerd — bestelgegevens bijgewerkt");
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
const msg = res?.error ?? "Opnieuw analyseren mislukt";
|
|
233
|
+
setReparseError(msg);
|
|
234
|
+
toast.error(msg);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
onError: (e) => {
|
|
238
|
+
const msg = e?.message ?? "Opnieuw analyseren mislukt";
|
|
239
|
+
setReparseError(msg);
|
|
240
|
+
toast.error(msg);
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
const didAgentCheck = useRef(false);
|
|
244
|
+
useEffect(() => {
|
|
245
|
+
if (didAgentCheck.current)
|
|
246
|
+
return;
|
|
247
|
+
didAgentCheck.current = true;
|
|
248
|
+
agentRefreshMut.mutate();
|
|
249
|
+
const channel = supabase
|
|
250
|
+
.channel("ai-agent-changes")
|
|
251
|
+
.on("postgres_changes", { event: "*", schema: "public", table: "ai_module_bindings" }, () => agentRefreshMut.mutate())
|
|
252
|
+
.on("postgres_changes", { event: "*", schema: "public", table: "ai_models" }, () => agentRefreshMut.mutate())
|
|
253
|
+
.on("postgres_changes", { event: "*", schema: "public", table: "ai_credentials" }, () => agentRefreshMut.mutate())
|
|
254
|
+
.subscribe();
|
|
255
|
+
const t = setInterval(() => agentRefreshMut.mutate(), 120000);
|
|
256
|
+
return () => { clearInterval(t); void supabase.removeChannel(channel); };
|
|
257
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
258
|
+
}, []);
|
|
123
259
|
const items = Array.isArray(data?.items) ? data.items : [];
|
|
124
260
|
const template = Array.isArray(templateData?.items) ? templateData.items : [];
|
|
125
261
|
const customers = Array.isArray(customersData?.items) ? customersData.items : [];
|
|
126
262
|
const products = Array.isArray(productsData?.items) ? productsData.items : [];
|
|
263
|
+
// Rule Engine bepaalt of doorverkopen zonder voorraad mag
|
|
264
|
+
const allowBackorder = productsData?.allowBackorder === true;
|
|
127
265
|
const plantTypes = Array.isArray(plantData?.items) ? plantData.items : [];
|
|
128
266
|
const suppliers = Array.isArray(supplierData?.items) ? supplierData.items : [];
|
|
129
267
|
const carriers = Array.isArray(carriersData?.items) ? carriersData.items : [];
|
|
@@ -135,7 +273,7 @@ export function MailboxProposalsPage() {
|
|
|
135
273
|
const customerNameFor = (p) => {
|
|
136
274
|
const fvs = Array.isArray(p.mailbox_proposal_field_values) ? p.mailbox_proposal_field_values : [];
|
|
137
275
|
const fv = fvs.find((v) => v.field_key === "customer");
|
|
138
|
-
const id = fv?.value
|
|
276
|
+
const id = resolveCustomerId(fv?.value, customers, p.matched_customer_id);
|
|
139
277
|
if (id) {
|
|
140
278
|
const c = customers.find((x) => x.id === id);
|
|
141
279
|
if (c?.company_name)
|
|
@@ -143,17 +281,24 @@ export function MailboxProposalsPage() {
|
|
|
143
281
|
}
|
|
144
282
|
return p.mailbox_messages?.from_name || p.mailbox_messages?.from_email || "Onbekend";
|
|
145
283
|
};
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
284
|
+
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) => {
|
|
285
|
+
const fvs = Array.isArray(p.mailbox_proposal_field_values) ? p.mailbox_proposal_field_values : [];
|
|
286
|
+
const needsReview = fvs.some((v) => v.needs_review);
|
|
287
|
+
const date = p.mailbox_messages?.received_at ?? p.created_at;
|
|
288
|
+
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));
|
|
289
|
+
}) })] }) }) })) }), _jsx(Dialog, { open: !!active, onOpenChange: (o) => { if (!o) {
|
|
290
|
+
setActiveId(null);
|
|
291
|
+
setReparseError(null);
|
|
292
|
+
} }, 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
293
|
try {
|
|
155
|
-
await fetchApprove({ data: payload });
|
|
294
|
+
const res = await fetchApprove({ data: payload });
|
|
295
|
+
const shortages = Array.isArray(res?.shortages) ? res.shortages : [];
|
|
156
296
|
toast.success("Bestelling aangemaakt — karindeling wordt automatisch aangemaakt in de Verkoop-module");
|
|
297
|
+
if (shortages.length > 0) {
|
|
298
|
+
toast.warning(`Tekorten (levering zonder voorraad staat aan): ${shortages
|
|
299
|
+
.map((s) => `${s.product ?? s.productId}: besteld ${s.wanted}, beschikbaar ${s.available}, tekort ${s.missing}`)
|
|
300
|
+
.join(" · ")}`);
|
|
301
|
+
}
|
|
157
302
|
qc.invalidateQueries({ queryKey: ["mailbox-proposals"] });
|
|
158
303
|
setActiveId(null);
|
|
159
304
|
}
|
|
@@ -175,7 +320,7 @@ export function MailboxProposalsPage() {
|
|
|
175
320
|
function ReadOnlyField({ label, value }) {
|
|
176
321
|
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
322
|
}
|
|
178
|
-
function ProposalEditor({ proposal, template, customers, products, plantTypes, suppliers, carriers, pickupLocations, onApprove, onReject, }) {
|
|
323
|
+
function ProposalEditor({ proposal, template, customers, products, allowBackorder, plantTypes, suppliers, carriers, pickupLocations, onApprove, onReject, }) {
|
|
179
324
|
const msg = proposal.mailbox_messages ?? {};
|
|
180
325
|
const fieldValues = Array.isArray(proposal.mailbox_proposal_field_values)
|
|
181
326
|
? proposal.mailbox_proposal_field_values : [];
|
|
@@ -191,12 +336,23 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
|
|
|
191
336
|
visibleFields.forEach((f) => {
|
|
192
337
|
init[f.key] = fvMap[f.key]?.value ?? f.default_value ?? null;
|
|
193
338
|
});
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
339
|
+
// De AI schrijft soms een klantnaam i.p.v. een id — altijd omzetten naar een
|
|
340
|
+
// geldig klant-id, met de automatisch herkende klant als terugval.
|
|
341
|
+
init["customer"] = resolveCustomerId(init["customer"], customers, proposal.matched_customer_id);
|
|
198
342
|
return init;
|
|
199
343
|
});
|
|
344
|
+
// Klantenlijst kan later binnenkomen: alsnog omzetten zodra dat kan.
|
|
345
|
+
useEffect(() => {
|
|
346
|
+
setValues((prev) => {
|
|
347
|
+
const resolved = resolveCustomerId(prev["customer"], customers, proposal.matched_customer_id);
|
|
348
|
+
return resolved === prev["customer"] ? prev : { ...prev, customer: resolved };
|
|
349
|
+
});
|
|
350
|
+
}, [customers, proposal.matched_customer_id]);
|
|
351
|
+
const defaultBelading = (p) => p ? {
|
|
352
|
+
plants_per_crate: Math.max(1, Number(p.plants_per_crate ?? 1)),
|
|
353
|
+
crates_per_layer: Math.max(1, Number(p.crates_per_layer ?? 1)),
|
|
354
|
+
layers_per_cart: Math.max(1, Number(p.layers_per_cart ?? 1)),
|
|
355
|
+
} : null;
|
|
200
356
|
const initialLines = Array.isArray(proposal.mailbox_proposal_lines)
|
|
201
357
|
? proposal.mailbox_proposal_lines.map((l) => {
|
|
202
358
|
const p = products.find((x) => x.id === l.product_id);
|
|
@@ -207,22 +363,51 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
|
|
|
207
363
|
id: l.id,
|
|
208
364
|
product_id: l.product_id,
|
|
209
365
|
raw_product_text: l.raw_product_text ?? "",
|
|
366
|
+
plant_type: l.plant_type ?? null,
|
|
210
367
|
quantity: normalized || null,
|
|
211
368
|
unit: l.unit,
|
|
369
|
+
// Geen belading bekend uit de mail → altijd de standaardbelading.
|
|
370
|
+
belading: defaultBelading(p),
|
|
212
371
|
};
|
|
213
372
|
})
|
|
214
373
|
: [];
|
|
215
374
|
const [lines, setLines] = useState(initialLines.length ? initialLines : [
|
|
216
|
-
{ product_id: null, raw_product_text: "", quantity: null, unit: "stuks" },
|
|
375
|
+
{ product_id: null, raw_product_text: "", quantity: null, unit: "stuks", belading: null },
|
|
217
376
|
]);
|
|
377
|
+
// Beschikbare beladingen per gekozen product (zelfde bron als Verkoop-module).
|
|
378
|
+
const fetchBeladingen = useServerFn(listBeladingOptionsForProducts);
|
|
379
|
+
const lineProductIds = useMemo(() => Array.from(new Set(lines.map((l) => l.product_id).filter(Boolean))), [lines]);
|
|
380
|
+
const { data: beladingData, isFetching: loadingBeladingen } = useQuery({
|
|
381
|
+
queryKey: ["mailbox-beladingen", lineProductIds.join(",")],
|
|
382
|
+
enabled: lineProductIds.length > 0,
|
|
383
|
+
queryFn: async () => {
|
|
384
|
+
try {
|
|
385
|
+
return await fetchBeladingen({ data: { productIds: lineProductIds } });
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return { byProduct: {} };
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
const beladingByProduct = beladingData?.byProduct && typeof beladingData.byProduct === "object"
|
|
393
|
+
? beladingData.byProduct : {};
|
|
218
394
|
const customerVal = values["customer"];
|
|
219
395
|
const deliveryVal = values["delivery_date"];
|
|
220
396
|
const notesVal = values["notes"];
|
|
221
397
|
const setVal = (key, v) => setValues((prev) => ({ ...prev, [key]: v }));
|
|
398
|
+
// Externe klant gekozen die nog niet lokaal bestaat → verplichte aanmaakdialoog.
|
|
399
|
+
const [newCustomer, setNewCustomer] = useState(null);
|
|
400
|
+
const handleSelectCustomer = (v) => {
|
|
401
|
+
setVal("customer", v);
|
|
402
|
+
if (v && !isLocalCustomerId(v)) {
|
|
403
|
+
const c = customers.find((x) => x.id === v) ?? null;
|
|
404
|
+
setNewCustomer(c);
|
|
405
|
+
}
|
|
406
|
+
};
|
|
222
407
|
const fetchDeliveryLocations = useServerFn(listDeliveryLocationsForCustomer);
|
|
223
408
|
const { data: deliveryLocData } = useQuery({
|
|
224
409
|
queryKey: ["mailbox-delivery-locations", customerVal],
|
|
225
|
-
enabled:
|
|
410
|
+
enabled: isLocalCustomerId(customerVal),
|
|
226
411
|
queryFn: async () => {
|
|
227
412
|
try {
|
|
228
413
|
return await fetchDeliveryLocations({ data: { customerId: customerVal } });
|
|
@@ -242,6 +427,15 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
|
|
|
242
427
|
// schedule reset via microtask om setState-in-render te vermijden
|
|
243
428
|
queueMicrotask(() => setVal("delivery_location", null));
|
|
244
429
|
}
|
|
430
|
+
// Geen locatie uit de mail/AI? Val terug op de hoofdafleverlocatie van de klant.
|
|
431
|
+
useEffect(() => {
|
|
432
|
+
if (deliveryLocationVal || deliveryLocations.length === 0)
|
|
433
|
+
return;
|
|
434
|
+
const primary = deliveryLocations.find((l) => l.is_primary === true) ?? deliveryLocations[0];
|
|
435
|
+
if (primary?.id)
|
|
436
|
+
setVal("delivery_location", primary.id);
|
|
437
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
438
|
+
}, [deliveryLocationVal, deliveryLocations.length, customerVal]);
|
|
245
439
|
// Auto-vul vervoerder zodra een nieuwe locatie wordt gekozen (één keer per wissel).
|
|
246
440
|
const lastLocRef = useRef(deliveryLocationVal);
|
|
247
441
|
useEffect(() => {
|
|
@@ -272,57 +466,124 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
|
|
|
272
466
|
const selectedCarrier = carriers.find((c) => c.id === carrierVal) ?? null;
|
|
273
467
|
const carrierDeliveryDays = Math.max(0, Number(selectedCarrier?.delivery_days ?? 0));
|
|
274
468
|
const pickupLocationVal = values["pickup_location"];
|
|
469
|
+
// ─── Ladingdragers uit de Rule Engine (nooit cachen) ──────────────────────
|
|
470
|
+
const fetchLoadCarriers = useServerFn(listAllowedLoadCarriers);
|
|
471
|
+
const { data: lcData } = useQuery({
|
|
472
|
+
queryKey: ["mailbox-load-carriers"],
|
|
473
|
+
staleTime: 0,
|
|
474
|
+
gcTime: 0,
|
|
475
|
+
refetchOnMount: "always",
|
|
476
|
+
refetchOnWindowFocus: true,
|
|
477
|
+
queryFn: async () => {
|
|
478
|
+
try {
|
|
479
|
+
return await fetchLoadCarriers();
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return EMPTY_LOAD_CARRIERS;
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
const loadCarrierList = useMemo(() => normalizeAllowedLoadCarriers(lcData), [lcData]);
|
|
487
|
+
const loadCarrierOptions = enabledLoadCarriers(loadCarrierList);
|
|
488
|
+
const loadCarrierVal = values["load_carrier"];
|
|
489
|
+
const loadCarrierBlocked = proposal.parsed_payload?.load_carrier_blocked === true
|
|
490
|
+
? (proposal.parsed_payload?.load_carrier_detected ?? "onbekend")
|
|
491
|
+
: null;
|
|
492
|
+
// Onbekende of uitgeschakelde drager wissen; niets herkend → standaard van de Engine.
|
|
493
|
+
useEffect(() => {
|
|
494
|
+
if (loadCarrierOptions.length === 0)
|
|
495
|
+
return;
|
|
496
|
+
if (loadCarrierVal && !loadCarrierOptions.some((c) => c.code === loadCarrierVal)) {
|
|
497
|
+
setVal("load_carrier", null);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (!loadCarrierVal) {
|
|
501
|
+
const def = loadCarrierOptions.find((c) => c.code === loadCarrierList.default) ?? loadCarrierOptions[0];
|
|
502
|
+
if (def)
|
|
503
|
+
setVal("load_carrier", def.code);
|
|
504
|
+
}
|
|
505
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
506
|
+
}, [loadCarrierVal, loadCarrierOptions.length, loadCarrierList.default]);
|
|
507
|
+
// Geen afhaallocatie uit de mail/AI? Val terug op de hoofd-afhaallocatie (isDefault).
|
|
508
|
+
useEffect(() => {
|
|
509
|
+
if (pickupLocations.length === 0)
|
|
510
|
+
return;
|
|
511
|
+
const known = pickupLocationVal && pickupLocations.some((l) => l.id === pickupLocationVal);
|
|
512
|
+
if (known)
|
|
513
|
+
return;
|
|
514
|
+
const main = pickupLocations.find((l) => l.is_default === true) ?? pickupLocations[0];
|
|
515
|
+
if (main?.id)
|
|
516
|
+
setVal("pickup_location", main.id);
|
|
517
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
518
|
+
}, [pickupLocationVal, pickupLocations.length]);
|
|
275
519
|
const canPickDate = !!customerVal && !!deliveryLocationVal && !!pickupLocationVal && !!carrierVal;
|
|
276
520
|
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);
|
|
521
|
+
const earliest = useMemo(() => computeEarliestDelivery(selectedCarrier, pickDays), [selectedCarrier, pickDays]);
|
|
522
|
+
const earliestDate = earliest.date;
|
|
523
|
+
const earliestISO = earliest.iso;
|
|
284
524
|
const earliestLabel = earliestDate.toLocaleDateString("nl-NL", {
|
|
285
525
|
day: "numeric", month: "long", year: "numeric",
|
|
286
526
|
});
|
|
287
|
-
//
|
|
527
|
+
// Bij wisselen van vervoerder: bezorgdatum altijd opnieuw bepalen.
|
|
528
|
+
const lastCarrierRef = useRef(carrierVal);
|
|
529
|
+
useEffect(() => {
|
|
530
|
+
if (carrierVal === lastCarrierRef.current)
|
|
531
|
+
return;
|
|
532
|
+
lastCarrierRef.current = carrierVal;
|
|
533
|
+
if (!carrierVal)
|
|
534
|
+
return;
|
|
535
|
+
setVal("delivery_date", earliestISO);
|
|
536
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
537
|
+
}, [carrierVal, earliestISO]);
|
|
538
|
+
// Synchroniseer delivery_date wanneer de eerst mogelijke datum verandert of
|
|
539
|
+
// de huidige datum niet geldig is voor de gekozen vervoerder.
|
|
288
540
|
useEffect(() => {
|
|
289
541
|
if (!canPickDate)
|
|
290
542
|
return;
|
|
291
|
-
if (!deliveryVal || deliveryVal
|
|
543
|
+
if (!deliveryVal || !isValidDeliveryDate(selectedCarrier, deliveryVal, pickDays)) {
|
|
292
544
|
setVal("delivery_date", earliestISO);
|
|
293
545
|
}
|
|
294
546
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
295
|
-
}, [canPickDate, earliestISO]);
|
|
296
|
-
const
|
|
547
|
+
}, [canPickDate, earliestISO, deliveryVal, carrierVal]);
|
|
548
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
549
|
+
const [submitting, setSubmitting] = useState(false);
|
|
550
|
+
const [checkedOk, setCheckedOk] = useState(false);
|
|
551
|
+
const buildPayload = () => {
|
|
297
552
|
const missing = template.filter((f) => f.required && (values[f.key] === null || values[f.key] === undefined || values[f.key] === ""));
|
|
298
553
|
if (missing.length > 0) {
|
|
299
554
|
toast.error(`Verplichte velden ontbreken: ${missing.map((m) => m.label).join(", ")}`);
|
|
300
|
-
return;
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
if (customerVal && !isLocalCustomerId(customerVal)) {
|
|
558
|
+
toast.error("Maak deze klant eerst aan voordat je de bestelling verwerkt.");
|
|
559
|
+
setNewCustomer(customers.find((c) => c.id === customerVal) ?? null);
|
|
560
|
+
return null;
|
|
301
561
|
}
|
|
302
562
|
if (!deliveryLocationVal) {
|
|
303
563
|
toast.error("Selecteer een afleverlocatie.");
|
|
304
|
-
return;
|
|
564
|
+
return null;
|
|
305
565
|
}
|
|
306
566
|
if (!pickupLocationVal) {
|
|
307
567
|
toast.error("Selecteer een afhaallocatie (eigen locatie / tuin).");
|
|
308
|
-
return;
|
|
568
|
+
return null;
|
|
309
569
|
}
|
|
310
570
|
const validLines = lines.filter((l) => l.product_id && (l.quantity ?? 0) > 0);
|
|
311
571
|
if (validLines.length === 0) {
|
|
312
572
|
toast.error("Voeg minimaal één product toe aan de bestelling.");
|
|
313
|
-
return;
|
|
573
|
+
return null;
|
|
314
574
|
}
|
|
315
575
|
for (const l of validLines) {
|
|
316
576
|
const p = products.find((x) => x.id === l.product_id);
|
|
317
|
-
const
|
|
577
|
+
const b = l.belading ?? defaultBelading(p);
|
|
578
|
+
const ppc = Math.max(1, Number(b?.plants_per_crate ?? 1));
|
|
318
579
|
const qty = l.quantity ?? 0;
|
|
319
580
|
if (qty < ppc || qty % ppc !== 0) {
|
|
320
581
|
toast.error(`"${p?.product ?? l.raw_product_text}" moet per hele fust besteld worden (minimaal 1 fust = ${ppc} planten).`);
|
|
321
|
-
return;
|
|
582
|
+
return null;
|
|
322
583
|
}
|
|
323
584
|
}
|
|
324
585
|
const cust = customers.find((c) => c.id === customerVal);
|
|
325
|
-
|
|
586
|
+
return {
|
|
326
587
|
proposalId: proposal.id,
|
|
327
588
|
customerId: customerVal ?? null,
|
|
328
589
|
customerName: cust?.company_name ?? msg.from_name ?? msg.from_email ?? "Onbekend",
|
|
@@ -330,17 +591,49 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
|
|
|
330
591
|
notes: notesVal || null,
|
|
331
592
|
lines: validLines,
|
|
332
593
|
fieldValues: Object.entries(values).map(([field_key, value]) => ({ field_key, value })),
|
|
333
|
-
}
|
|
594
|
+
};
|
|
334
595
|
};
|
|
335
|
-
|
|
336
|
-
|
|
596
|
+
// Stap 1: controleren → bevestigingsdialoog. Pas na bevestigen wordt de
|
|
597
|
+
// bestelling daadwerkelijk aangemaakt in Bestellingen.
|
|
598
|
+
const handleApprove = () => {
|
|
599
|
+
if (!buildPayload())
|
|
600
|
+
return;
|
|
601
|
+
setConfirmOpen(true);
|
|
602
|
+
};
|
|
603
|
+
const handleConfirm = async () => {
|
|
604
|
+
const payload = buildPayload();
|
|
605
|
+
if (!payload)
|
|
606
|
+
return;
|
|
607
|
+
setSubmitting(true);
|
|
608
|
+
try {
|
|
609
|
+
await onApprove(payload);
|
|
610
|
+
setConfirmOpen(false);
|
|
611
|
+
}
|
|
612
|
+
finally {
|
|
613
|
+
setSubmitting(false);
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
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: [(() => {
|
|
617
|
+
// Klantveld staat altijd bovenaan, ook als het template (nog) leeg is.
|
|
618
|
+
const customerField = visibleFields.find((f) => f.key === "customer") ?? {
|
|
619
|
+
id: "customer",
|
|
620
|
+
key: "customer",
|
|
621
|
+
label: "Klant",
|
|
622
|
+
field_type: "customer",
|
|
623
|
+
required: true,
|
|
624
|
+
visible: true,
|
|
625
|
+
};
|
|
337
626
|
const deliveryDateField = visibleFields.find((f) => f.key === "delivery_date");
|
|
338
627
|
const otherFields = visibleFields.filter((f) => f.key !== "customer" && f.key !== "delivery_date");
|
|
339
|
-
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));
|
|
628
|
+
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, onSelectCustomer: handleSelectCustomer }, field.key));
|
|
629
|
+
const customerNotLocal = !!customerVal && !isLocalCustomerId(customerVal);
|
|
340
630
|
const payload = (proposal.parsed_payload ?? {});
|
|
341
631
|
const autoMatched = payload.auto_matched_customer === true;
|
|
342
632
|
const matchWarning = typeof payload.auto_match_warning === "string" ? payload.auto_match_warning : null;
|
|
343
|
-
|
|
633
|
+
const ai = (payload.ai ?? null);
|
|
634
|
+
return (_jsxs(_Fragment, { children: [ai && ai.ok === false && (_jsx("div", { className: "rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800", children: ai.retry_pending
|
|
635
|
+
? "De AI-dienst was even bezet — we proberen deze mail automatisch opnieuw. Je kunt de velden intussen zelf invullen."
|
|
636
|
+
: "Deze mail is niet automatisch uitgelezen — vul de velden handmatig in." })), 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), customerNotLocal && (_jsxs("div", { className: "rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 flex items-center justify-between gap-2", children: [_jsx("span", { children: "Deze klant bestaat nog niet lokaal. Maak de klant eerst aan." }), _jsx(Button, { size: "sm", variant: "outline", onClick: () => setNewCustomer(customers.find((c) => c.id === customerVal) ?? null), children: "Klant aanmaken" })] })), _jsxs("div", { className: "space-y-1", children: [_jsx(Label, { children: "Locatie" }), _jsx(SearchableSelect, { value: deliveryLocationVal, placeholder: !customerVal
|
|
344
637
|
? "Selecteer eerst een klant"
|
|
345
638
|
: deliveryLocations.length
|
|
346
639
|
? "Kies locatie..."
|
|
@@ -355,14 +648,14 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
|
|
|
355
648
|
label: (l.name || addr || "Locatie") + (l.is_primary ? " ★" : ""),
|
|
356
649
|
hint: l.name && addr ? addr : undefined,
|
|
357
650
|
};
|
|
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
|
|
651
|
+
}), 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
652
|
? "Selecteer eerst een klant"
|
|
360
653
|
: !deliveryLocationVal
|
|
361
654
|
? "Selecteer eerst een locatie"
|
|
362
655
|
: "Kies vervoerder...", clearLabel: "\u2014 Geen \u2014", disabled: !deliveryLocationVal, options: carriers.map((c) => ({
|
|
363
656
|
value: c.id,
|
|
364
657
|
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: [
|
|
658
|
+
})), 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
659
|
const v = e.target.value || null;
|
|
367
660
|
if (v && v < earliestISO) {
|
|
368
661
|
toast.error(`Eerst mogelijke leverdatum is ${earliestLabel}.`);
|
|
@@ -370,10 +663,39 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
|
|
|
370
663
|
return;
|
|
371
664
|
}
|
|
372
665
|
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
|
-
|
|
666
|
+
} }), !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
|
|
667
|
+
? `, 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" })})`
|
|
668
|
+
: `, op basis van orderpicktijd (${pickDays}d) en levertijd vervoerder (${carrierDeliveryDays}d)`, "."] }))] }), otherFields.map(renderField)] }));
|
|
669
|
+
})(), _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
670
|
const product = line.product_id ? products.find((p) => p.id === line.product_id) : null;
|
|
376
|
-
const
|
|
671
|
+
const stdBelading = defaultBelading(product);
|
|
672
|
+
const available = (line.product_id ? (Array.isArray(beladingByProduct[line.product_id]) ? beladingByProduct[line.product_id] : []) : []).map((b) => ({
|
|
673
|
+
plants_per_crate: Math.max(1, Number(b.plants_per_crate ?? 1)),
|
|
674
|
+
crates_per_layer: Math.max(1, Number(b.crates_per_layer ?? 1)),
|
|
675
|
+
layers_per_cart: Math.max(1, Number(b.layers_per_cart ?? 1)),
|
|
676
|
+
crate_code: b.crate_code ?? null,
|
|
677
|
+
crate_name: b.crate_name ?? null,
|
|
678
|
+
carrier_code: b.carrier_code ?? null,
|
|
679
|
+
carrier_name: b.carrier_name ?? null,
|
|
680
|
+
})).filter((b) => beladingAllowedByEngine(b, loadCarrierOptions));
|
|
681
|
+
// Standaardbelading: gebruik de variant mét fust-/kartype als die bestaat.
|
|
682
|
+
const sameDims = (a, b) => a.plants_per_crate === b.plants_per_crate &&
|
|
683
|
+
a.crates_per_layer === b.crates_per_layer &&
|
|
684
|
+
a.layers_per_cart === b.layers_per_cart;
|
|
685
|
+
const richDefault = stdBelading ? available.find((b) => sameDims(b, stdBelading)) ?? null : null;
|
|
686
|
+
const effDefault = richDefault ?? stdBelading;
|
|
687
|
+
const optionMap = new Map();
|
|
688
|
+
for (const b of available)
|
|
689
|
+
optionMap.set(beladingKey(b), b);
|
|
690
|
+
if (effDefault)
|
|
691
|
+
optionMap.set(beladingKey(effDefault), effDefault);
|
|
692
|
+
const defaultKey = effDefault ? beladingKey(effDefault) : null;
|
|
693
|
+
// Geen belading in de mail → altijd de standaardbelading van het product.
|
|
694
|
+
const chosenBelading = line.belading ?? effDefault;
|
|
695
|
+
const effBelading = chosenBelading && !optionMap.has(beladingKey(chosenBelading))
|
|
696
|
+
? (available.find((b) => sameDims(b, chosenBelading)) ?? chosenBelading)
|
|
697
|
+
: chosenBelading;
|
|
698
|
+
const ppc = Math.max(1, Number(effBelading?.plants_per_crate ?? product?.plants_per_crate ?? 1));
|
|
377
699
|
const plants = line.quantity ?? null;
|
|
378
700
|
const cratesExact = plants !== null ? plants / ppc : null;
|
|
379
701
|
const cratesDisplay = cratesExact === null ? "" : String(Math.ceil(cratesExact));
|
|
@@ -382,48 +704,134 @@ function ProposalEditor({ proposal, template, customers, products, plantTypes, s
|
|
|
382
704
|
e.preventDefault();
|
|
383
705
|
}
|
|
384
706
|
};
|
|
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
|
-
|
|
707
|
+
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) => {
|
|
708
|
+
const stock = Number(p.available_stock ?? 0);
|
|
709
|
+
const out = stock <= 0;
|
|
710
|
+
return {
|
|
711
|
+
value: p.id,
|
|
712
|
+
label: p.product,
|
|
713
|
+
hint: `${p.unit ?? ""}${p.available_stock != null ? ` · voorraad: ${p.available_stock}` : ""}${out ? (allowBackorder ? " · niet op voorraad (doorverkoop toegestaan)" : " · niet op voorraad") : ""}`.trim(),
|
|
714
|
+
disabled: out && !allowBackorder && p.id !== line.product_id,
|
|
715
|
+
};
|
|
716
|
+
}), onChange: (v) => {
|
|
717
|
+
const next = [...lines];
|
|
718
|
+
if (v === null)
|
|
719
|
+
next[idx] = { ...next[idx], product_id: null, belading: null };
|
|
720
|
+
else {
|
|
721
|
+
const p = products.find((x) => x.id === v);
|
|
722
|
+
const b = defaultBelading(p);
|
|
723
|
+
const newPpc = Math.max(1, Number(b?.plants_per_crate ?? 1));
|
|
724
|
+
const currentQ = Number(next[idx].quantity ?? 0);
|
|
725
|
+
const normalizedQ = currentQ > 0 ? Math.ceil(currentQ / newPpc) * newPpc : newPpc;
|
|
726
|
+
next[idx] = {
|
|
727
|
+
...next[idx], product_id: v,
|
|
728
|
+
raw_product_text: p?.product ?? next[idx].raw_product_text,
|
|
729
|
+
unit: next[idx].unit ?? p?.unit ?? "stuks",
|
|
730
|
+
quantity: normalizedQ,
|
|
731
|
+
belading: b,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
setLines(next);
|
|
735
|
+
} }), (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"
|
|
736
|
+
: loadingBeladingen ? "Beladingen laden..."
|
|
737
|
+
: "Kies belading...", disabled: !line.product_id, allowClear: false, emptyLabel: "Geen belading gekoppeld aan dit product", options: Array.from(optionMap.entries()).map(([key, b]) => ({
|
|
738
|
+
value: key,
|
|
739
|
+
label: beladingLabel(b),
|
|
740
|
+
hint: (key === defaultKey ? "Standaard van product · " : "") +
|
|
741
|
+
beladingHint(b),
|
|
742
|
+
})), onChange: (v) => {
|
|
743
|
+
if (!v)
|
|
744
|
+
return;
|
|
745
|
+
const b = optionMap.get(v);
|
|
746
|
+
if (!b)
|
|
747
|
+
return;
|
|
748
|
+
const next = [...lines];
|
|
749
|
+
const crates = Math.max(1, Math.ceil(Number(next[idx].quantity ?? 0) / ppc));
|
|
398
750
|
next[idx] = {
|
|
399
|
-
...next[idx],
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
quantity:
|
|
751
|
+
...next[idx],
|
|
752
|
+
belading: b,
|
|
753
|
+
// Fusten behouden, planten herrekenen op de nieuwe belading.
|
|
754
|
+
quantity: crates * b.plants_per_crate,
|
|
403
755
|
};
|
|
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) => {
|
|
756
|
+
setLines(next);
|
|
757
|
+
} }), 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
758
|
const next = [...lines];
|
|
415
759
|
const raw = e.target.value.replace(/[.,]/g, "");
|
|
416
760
|
const cr = raw ? Math.max(1, Math.floor(Number(raw))) : null;
|
|
417
761
|
next[idx] = { ...next[idx], quantity: cr === null ? null : cr * ppc };
|
|
418
762
|
setLines(next);
|
|
419
|
-
}, placeholder: "Fusten", title: `${ppc} planten per fust — verkoop per hele fust` }), _jsx(
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
763
|
+
}, 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));
|
|
764
|
+
}), _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) => {
|
|
765
|
+
if (submitting)
|
|
766
|
+
return;
|
|
767
|
+
setConfirmOpen(o);
|
|
768
|
+
if (!o)
|
|
769
|
+
setCheckedOk(false);
|
|
770
|
+
}, 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" })] })] }) }), _jsx(NewCustomerDialog, { candidate: newCustomer, onClose: () => setNewCustomer(null), onCreated: (id) => { setNewCustomer(null); setVal("customer", id); } })] })] })] }));
|
|
771
|
+
}
|
|
772
|
+
/** Verplichte bevestiging: externe klant eerst als lokale klant aanmaken. */
|
|
773
|
+
function NewCustomerDialog({ candidate, onClose, onCreated, }) {
|
|
774
|
+
const qc = useQueryClient();
|
|
775
|
+
const createCustomer = useServerFn(createCustomerFromGroup);
|
|
776
|
+
const [form, setForm] = useState({});
|
|
777
|
+
const [busy, setBusy] = useState(false);
|
|
778
|
+
useEffect(() => {
|
|
779
|
+
if (!candidate)
|
|
780
|
+
return;
|
|
781
|
+
setForm({
|
|
782
|
+
companyName: candidate.company_name ?? "",
|
|
783
|
+
email: candidate.email ?? "",
|
|
784
|
+
street: candidate.street ?? "",
|
|
785
|
+
houseNumber: candidate.house_number ?? "",
|
|
786
|
+
postalCode: candidate.postal_code ?? "",
|
|
787
|
+
city: candidate.city ?? "",
|
|
788
|
+
country: candidate.country ?? "",
|
|
789
|
+
gln: candidate.gln ?? "",
|
|
790
|
+
phone: candidate.phone ?? "",
|
|
791
|
+
});
|
|
792
|
+
}, [candidate?.id]);
|
|
793
|
+
const set = (k, v) => setForm((p) => ({ ...p, [k]: v }));
|
|
794
|
+
const field = (k, label, required = false) => (_jsxs("div", { className: "space-y-1", children: [_jsxs(Label, { children: [label, required && _jsx("span", { className: "text-destructive", children: " *" })] }), _jsx(Input, { value: form[k] ?? "", onChange: (e) => set(k, e.target.value) })] }));
|
|
795
|
+
const submit = async () => {
|
|
796
|
+
if (!candidate)
|
|
797
|
+
return;
|
|
798
|
+
if (!(form["companyName"] ?? "").trim()) {
|
|
799
|
+
toast.error("Bedrijfsnaam is verplicht.");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
setBusy(true);
|
|
803
|
+
try {
|
|
804
|
+
const res = await createCustomer({
|
|
805
|
+
data: {
|
|
806
|
+
groupKey: candidate.group_key ?? candidate.id,
|
|
807
|
+
companyName: form["companyName"].trim(),
|
|
808
|
+
email: (form["email"] ?? "").trim() || null,
|
|
809
|
+
street: (form["street"] ?? "").trim() || null,
|
|
810
|
+
houseNumber: (form["houseNumber"] ?? "").trim() || null,
|
|
811
|
+
postalCode: (form["postalCode"] ?? "").trim() || null,
|
|
812
|
+
city: (form["city"] ?? "").trim() || null,
|
|
813
|
+
country: (form["country"] ?? "").trim() || null,
|
|
814
|
+
gln: (form["gln"] ?? "").trim() || null,
|
|
815
|
+
phone: (form["phone"] ?? "").trim() || null,
|
|
816
|
+
},
|
|
817
|
+
});
|
|
818
|
+
if (!res?.id)
|
|
819
|
+
throw new Error("Klant kon niet worden aangemaakt.");
|
|
820
|
+
await qc.invalidateQueries({ queryKey: ["mailbox-customers"] });
|
|
821
|
+
toast.success(res.created ? "Klant aangemaakt." : "Bestaande klant gekoppeld.");
|
|
822
|
+
onCreated(res.id);
|
|
823
|
+
}
|
|
824
|
+
catch (e) {
|
|
825
|
+
toast.error(e?.message ?? "Aanmaken mislukt.");
|
|
826
|
+
}
|
|
827
|
+
finally {
|
|
828
|
+
setBusy(false);
|
|
829
|
+
}
|
|
830
|
+
};
|
|
831
|
+
return (_jsx(Dialog, { open: !!candidate, onOpenChange: (o) => { if (!o)
|
|
832
|
+
onClose(); }, children: _jsxs(DialogContent, { className: "max-w-xl", children: [_jsx(DialogHeader, { children: _jsx(DialogTitle, { children: "Nieuwe klant aanmaken" }) }), _jsxs("p", { className: "text-sm text-muted-foreground", children: ["Deze klant bestaat alleen in ", (candidate?.providers ?? []).map(providerLabel).join(" + ") || "een externe bron", ". Controleer de gegevens en maak de klant aan voordat je de bestelling verwerkt."] }), _jsxs("div", { className: "grid grid-cols-2 gap-3 py-2", children: [field("companyName", "Bedrijfsnaam", true), field("email", "E-mail"), field("street", "Straat"), field("houseNumber", "Huisnummer"), field("postalCode", "Postcode"), field("city", "Plaats"), field("country", "Land"), field("gln", "GLN"), field("phone", "Telefoon")] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { className: "flex-1", disabled: busy, onClick: submit, children: busy ? "Bezig…" : "Klant aanmaken en koppelen" }), _jsx(Button, { variant: "outline", disabled: busy, onClick: onClose, children: "Annuleren" })] })] }) }));
|
|
425
833
|
}
|
|
426
|
-
function DynamicField({ field, value, meta, onChange, customers, products, plantTypes, suppliers, }) {
|
|
834
|
+
function DynamicField({ field, value, meta, onChange, customers, products, plantTypes, suppliers, onSelectCustomer, }) {
|
|
427
835
|
const reviewClass = meta?.needs_review ? "border-yellow-500 ring-1 ring-yellow-500/40 rounded-md p-2" : "";
|
|
428
836
|
const label = (_jsxs(Label, { className: "flex items-center gap-2", children: [field.label, field.required && _jsx("span", { className: "text-destructive", children: "*" }), meta?.needs_review && (_jsx(Badge, { variant: "outline", className: "text-xs border-yellow-500 text-yellow-700", children: "Controleren" })), typeof meta?.ai_confidence === "number" && (_jsxs("span", { className: "text-xs text-muted-foreground", children: [Math.round(meta.ai_confidence * 100), "%"] }))] }));
|
|
429
837
|
const renderControl = () => {
|
|
@@ -448,7 +856,11 @@ function DynamicField({ field, value, meta, onChange, customers, products, plant
|
|
|
448
856
|
}) }));
|
|
449
857
|
}
|
|
450
858
|
case "customer":
|
|
451
|
-
return (_jsx(SearchableSelect, { value: value ?? null, placeholder: "Kies klant...", options: customers.map((c) => ({
|
|
859
|
+
return (_jsx(SearchableSelect, { value: value ?? null, placeholder: "Kies klant...", options: customers.map((c) => ({
|
|
860
|
+
value: c.id,
|
|
861
|
+
label: c.company_name + (c.is_local === false ? " (nog niet aangemaakt)" : ""),
|
|
862
|
+
hint: customerHint(c),
|
|
863
|
+
})), onChange: (v) => (onSelectCustomer ? onSelectCustomer(v) : onChange(v)) }));
|
|
452
864
|
case "product":
|
|
453
865
|
return (_jsx(SearchableSelect, { value: value ?? null, placeholder: "Kies product...", options: products.map((p) => ({ value: p.id, label: p.product })), onChange: onChange }));
|
|
454
866
|
case "plant_type":
|