@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.
Files changed (79) hide show
  1. package/dist-lib/components/MailboxAuthBanner.d.ts +16 -0
  2. package/dist-lib/components/MailboxAuthBanner.d.ts.map +1 -0
  3. package/dist-lib/components/MailboxAuthBanner.js +14 -0
  4. package/dist-lib/components/MailboxProcessedPage.d.ts.map +1 -1
  5. package/dist-lib/components/MailboxProcessedPage.js +10 -2
  6. package/dist-lib/components/MailboxProposalsPage.d.ts +16 -0
  7. package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
  8. package/dist-lib/components/MailboxProposalsPage.js +520 -108
  9. package/dist-lib/components/SearchableSelect.d.ts +1 -0
  10. package/dist-lib/components/SearchableSelect.d.ts.map +1 -1
  11. package/dist-lib/components/SearchableSelect.js +2 -1
  12. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts +2 -0
  13. package/dist-lib/components/settings/MailboxAiPromptCard.d.ts.map +1 -0
  14. package/dist-lib/components/settings/MailboxAiPromptCard.js +39 -0
  15. package/dist-lib/index.d.ts.map +1 -1
  16. package/dist-lib/index.js +2 -0
  17. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
  18. package/dist-lib/integrations/supabase/auth-attacher.js +5 -1
  19. package/dist-lib/integrations/supabase/auth-middleware.d.ts +2 -11202
  20. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  21. package/dist-lib/integrations/supabase/auth-middleware.js +8 -7
  22. package/dist-lib/integrations/supabase/client.d.ts +16689 -7180
  23. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  24. package/dist-lib/integrations/supabase/client.server.d.ts +16691 -7181
  25. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  26. package/dist-lib/integrations/supabase/types.d.ts +16944 -7220
  27. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  28. package/dist-lib/integrations/supabase/types.js +63 -1
  29. package/dist-lib/lib/ai-config.server.d.ts +60 -0
  30. package/dist-lib/lib/ai-config.server.d.ts.map +1 -0
  31. package/dist-lib/lib/ai-config.server.js +112 -0
  32. package/dist-lib/lib/delivery-date.d.ts +37 -0
  33. package/dist-lib/lib/delivery-date.d.ts.map +1 -0
  34. package/dist-lib/lib/delivery-date.js +96 -0
  35. package/dist-lib/lib/load-carriers.d.ts +26 -0
  36. package/dist-lib/lib/load-carriers.d.ts.map +1 -0
  37. package/dist-lib/lib/load-carriers.js +73 -0
  38. package/dist-lib/lib/mailbox-ai-prompt.d.ts +9 -0
  39. package/dist-lib/lib/mailbox-ai-prompt.d.ts.map +1 -0
  40. package/dist-lib/lib/mailbox-ai-prompt.js +30 -0
  41. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts +23 -0
  42. package/dist-lib/lib/mailbox-ai-settings.functions.d.ts.map +1 -0
  43. package/dist-lib/lib/mailbox-ai-settings.functions.js +58 -0
  44. package/dist-lib/lib/mailbox-ai.server.d.ts +56 -4
  45. package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
  46. package/dist-lib/lib/mailbox-ai.server.js +329 -6
  47. package/dist-lib/lib/mailbox-body.d.ts +7 -0
  48. package/dist-lib/lib/mailbox-body.d.ts.map +1 -0
  49. package/dist-lib/lib/mailbox-body.js +38 -0
  50. package/dist-lib/lib/mailbox-ingest.server.d.ts +89 -0
  51. package/dist-lib/lib/mailbox-ingest.server.d.ts.map +1 -0
  52. package/dist-lib/lib/mailbox-ingest.server.js +928 -0
  53. package/dist-lib/lib/mailbox-template.functions.d.ts +7 -67213
  54. package/dist-lib/lib/mailbox-template.functions.d.ts.map +1 -1
  55. package/dist-lib/lib/mailbox.functions.d.ts +130 -190511
  56. package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
  57. package/dist-lib/lib/mailbox.functions.js +641 -274
  58. package/package.json +4 -3
  59. package/dist-lib/components/CartAssignmentWizard.d.ts +0 -6
  60. package/dist-lib/components/CartAssignmentWizard.d.ts.map +0 -1
  61. package/dist-lib/components/CartAssignmentWizard.js +0 -199
  62. package/dist-lib/components/MailboxInboxPage.d.ts +0 -2
  63. package/dist-lib/components/MailboxInboxPage.d.ts.map +0 -1
  64. package/dist-lib/components/MailboxInboxPage.js +0 -120
  65. package/dist-lib/components/settings/ImapAccountsCard.d.ts +0 -2
  66. package/dist-lib/components/settings/ImapAccountsCard.d.ts.map +0 -1
  67. package/dist-lib/components/settings/ImapAccountsCard.js +0 -136
  68. package/dist-lib/components/settings/ImapProfilesCard.d.ts +0 -2
  69. package/dist-lib/components/settings/ImapProfilesCard.d.ts.map +0 -1
  70. package/dist-lib/components/settings/ImapProfilesCard.js +0 -101
  71. package/dist-lib/components/settings/MailboxSettingsCard.d.ts +0 -2
  72. package/dist-lib/components/settings/MailboxSettingsCard.d.ts.map +0 -1
  73. package/dist-lib/components/settings/MailboxSettingsCard.js +0 -85
  74. package/dist-lib/lib/imap.functions.d.ts +0 -32852
  75. package/dist-lib/lib/imap.functions.d.ts.map +0 -1
  76. package/dist-lib/lib/imap.functions.js +0 -235
  77. package/dist-lib/lib/mailbox-auto.server.d.ts +0 -9
  78. package/dist-lib/lib/mailbox-auto.server.d.ts.map +0 -1
  79. package/dist-lib/lib/mailbox-auto.server.js +0 -198
@@ -3,6 +3,9 @@ import { createServerFn } from "@tanstack/react-start";
3
3
  import { requireSupabaseAuth } from "../integrations/supabase/auth-middleware";
4
4
  import { createClient } from "@supabase/supabase-js";
5
5
  import { z } from "zod";
6
+ import { computeEarliestDelivery } from "../lib/delivery-date";
7
+ import { emailBodyText } from "./mailbox-body";
8
+ import { EMPTY_LOAD_CARRIERS, isLoadCarrierAllowed, loadCarrierLabel, normalizeAllowedLoadCarriers, } from "./load-carriers";
6
9
  // ─── Actieve mailbox (uit gedeelde e-mail-auth module) ──────────────────────
7
10
  // Geen auth-middleware: de RPC is SECURITY DEFINER en EXECUTE staat ook aan
8
11
  // `anon`. Zo blijft de status-banner werken als de bearer-token tijdelijk
@@ -37,242 +40,80 @@ export const getActiveOrdersMailbox = createServerFn({ method: "GET" })
37
40
  needs_reauth: !!row.needs_reauth,
38
41
  };
39
42
  });
40
- // ─── Event ingest helpers ────────────────────────────────────────────────────
41
- function pickStr(obj, ...keys) {
42
- for (const k of keys) {
43
- const v = k.split(".").reduce((a, p) => (a == null ? a : a[p]), obj);
44
- if (typeof v === "string" && v.length > 0)
45
- return v;
46
- }
47
- return null;
48
- }
49
- async function ingestSingleEvent(supabaseAdmin, connectionId, ev) {
50
- const p = ev.payload ?? {};
51
- const externalId = ev.external_id ?? pickStr(p, "message_id", "messageId", "id") ?? ev.id;
52
- // Skip als deze external_id al bestaat
53
- if (externalId) {
54
- const { data: existing } = await supabaseAdmin
55
- .from("mailbox_messages")
56
- .select("id")
57
- .eq("external_id", externalId)
58
- .maybeSingle();
59
- if (existing?.id) {
60
- return { inserted: false, messageId: existing.id };
43
+ // ─── Ingest-triggers ────────────────────────────────────────────────────────
44
+ // De volledige ingest-pipeline (events → messages → stub-proposals) leeft in
45
+ // `src/lib/mailbox-ingest.server.ts`. Onderstaande server-fns zijn dunne
46
+ // wrappers zodat de client dezelfde pipeline kan afvuren als pg_cron.
47
+ // Bewust ZONDER `requireSupabaseAuth`-middleware: die gooit een raw `Response`
48
+ // (401/500) die in de browser als onleesbare "[object Response]"-runtimefout met
49
+ // blanco scherm eindigt. We verifiëren de bearer-token hier zelf en geven altijd
50
+ // nette JSON terug, zodat de UI de fout gewoon kan tonen.
51
+ export const syncMailboxNow = createServerFn({ method: "POST" })
52
+ .inputValidator((input) => z.object({ trigger: z.enum(["manual", "realtime"]).default("manual") }).parse(input ?? {}))
53
+ .handler(async ({ data }) => {
54
+ try {
55
+ const { getRequest } = await import("@tanstack/react-start/server");
56
+ const token = getRequest()?.headers?.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
57
+ const url = process.env.SUPABASE_URL;
58
+ const key = process.env.SUPABASE_PUBLISHABLE_KEY;
59
+ if (!token || !url || !key) {
60
+ return { ok: false, error: "unauthorized", processed: 0, inserted: 0, errors: 1 };
61
61
  }
62
- }
63
- const fromEmail = pickStr(p, "from_email", "fromEmail", "from.address", "from.email", "sender.address") ?? "";
64
- const fromName = pickStr(p, "from_name", "fromName", "from.name", "sender.name");
65
- const subject = pickStr(p, "subject", "title") ?? "";
66
- const bodyText = pickStr(p, "body_text", "bodyText", "text", "body.text", "snippet");
67
- const bodyHtml = pickStr(p, "body_html", "bodyHtml", "html", "body.html");
68
- const receivedAt = pickStr(p, "received_at", "receivedAt", "date", "internalDate") ?? ev.received_at ?? new Date().toISOString();
69
- const { data: inserted, error: insErr } = await supabaseAdmin
70
- .from("mailbox_messages")
71
- .insert({
72
- external_id: externalId,
73
- message_id: pickStr(p, "message_id", "messageId") ?? externalId,
74
- connection_id: connectionId,
75
- from_email: fromEmail,
76
- from_name: fromName,
77
- subject,
78
- body_text: bodyText,
79
- body_html: bodyHtml,
80
- received_at: receivedAt,
81
- status: "new",
82
- })
83
- .select("id")
84
- .single();
85
- if (insErr) {
86
- // Duplicate race → markeer alsnog als consumed
87
- if (insErr.code === "23505") {
88
- return { inserted: false, messageId: null };
62
+ const sb = createClient(url, key, {
63
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
64
+ });
65
+ const { data: userData, error: userErr } = await sb.auth.getUser(token);
66
+ if (userErr || !userData?.user) {
67
+ return { ok: false, error: "unauthorized", processed: 0, inserted: 0, errors: 1 };
89
68
  }
90
- throw new Error(insErr.message);
91
- }
92
- // Maak direct een leeg voorstel aan (AI staat uit) — match klant op afzender
93
- await createStubProposal(supabaseAdmin, inserted.id, fromEmail);
94
- return { inserted: true, messageId: inserted.id };
95
- }
96
- async function createStubProposal(supabaseAdmin, messageId, fromEmail) {
97
- const { data: existing } = await supabaseAdmin
98
- .from("mailbox_order_proposals").select("id").eq("message_id", messageId).maybeSingle();
99
- if (existing?.id)
100
- return;
101
- const matchResult = await matchCustomerByEmail(supabaseAdmin, fromEmail);
102
- await supabaseAdmin.from("mailbox_order_proposals").insert({
103
- message_id: messageId,
104
- matched_customer_id: matchResult.customerId,
105
- confidence: matchResult.customerId ? 1 : 0,
106
- status: "pending",
107
- parsed_payload: {
108
- stub: true,
109
- reason: "ai_disabled",
110
- auto_matched_customer: matchResult.customerId !== null,
111
- auto_match_source: matchResult.source,
112
- auto_match_email: fromEmail,
113
- auto_match_warning: matchResult.warning,
114
- auto_match_candidates: matchResult.candidates,
115
- },
116
- });
117
- }
118
- // Exact (case-insensitive) match op afzender-e-mailadres.
119
- // Eerst customers.email, dan customer_contacts.email als fallback.
120
- // Bij meerdere treffers: geen automatische koppeling, wel waarschuwing.
121
- async function matchCustomerByEmail(supabaseAdmin, fromEmail) {
122
- if (!fromEmail) {
123
- return { customerId: null, source: null, warning: null, candidates: [] };
124
- }
125
- const email = fromEmail.trim();
126
- if (!email) {
127
- return { customerId: null, source: null, warning: null, candidates: [] };
128
- }
129
- // 1) customers.email (case-insensitive exact)
130
- const { data: directHits } = await supabaseAdmin
131
- .from("customers")
132
- .select("id, company_name")
133
- .ilike("email", email)
134
- .limit(5);
135
- const direct = Array.isArray(directHits) ? directHits : [];
136
- if (direct.length === 1) {
137
- return {
138
- customerId: direct[0].id,
139
- source: "customers",
140
- warning: null,
141
- candidates: direct.map((c) => ({ id: c.id, name: c.company_name })),
142
- };
143
- }
144
- if (direct.length > 1) {
145
- return {
146
- customerId: null,
147
- source: "customers",
148
- warning: `Meerdere klanten met e-mailadres ${email} — kies handmatig.`,
149
- candidates: direct.map((c) => ({ id: c.id, name: c.company_name })),
150
- };
151
- }
152
- // 2) customer_contacts.email (case-insensitive exact)
153
- const { data: contactHits } = await supabaseAdmin
154
- .from("customer_contacts")
155
- .select("customer_id")
156
- .ilike("email", email)
157
- .limit(5);
158
- const contacts = Array.isArray(contactHits) ? contactHits : [];
159
- const uniqueCustomerIds = Array.from(new Set(contacts.map((c) => c.customer_id).filter(Boolean)));
160
- if (uniqueCustomerIds.length === 0) {
161
- return { customerId: null, source: null, warning: null, candidates: [] };
69
+ const { runIngest } = await import("../lib/mailbox-ingest.server");
70
+ return await runIngest(data.trigger);
162
71
  }
163
- const { data: custRows } = await supabaseAdmin
164
- .from("customers")
165
- .select("id, company_name")
166
- .in("id", uniqueCustomerIds);
167
- const cands = (Array.isArray(custRows) ? custRows : []).map((c) => ({
168
- id: c.id,
169
- name: c.company_name,
170
- }));
171
- if (uniqueCustomerIds.length === 1) {
72
+ catch (e) {
172
73
  return {
173
- customerId: uniqueCustomerIds[0],
174
- source: "customer_contacts",
175
- warning: null,
176
- candidates: cands,
74
+ ok: false,
75
+ error: String(e?.message ?? e),
76
+ processed: 0,
77
+ inserted: 0,
78
+ errors: 1,
177
79
  };
178
80
  }
179
- return {
180
- customerId: null,
181
- source: "customer_contacts",
182
- warning: `Meerdere klanten gevonden via contactpersoon met e-mailadres ${email} — kies handmatig.`,
183
- candidates: cands,
184
- };
185
- }
186
- // ─── Ingest één specifiek event (vanuit realtime subscription) ───────────────
187
- export const ingestEmailEvent = createServerFn({ method: "POST" })
81
+ });
82
+ export const getLastIngestRun = createServerFn({ method: "GET" })
188
83
  .middleware([requireSupabaseAuth])
189
- .inputValidator((input) => z.object({ eventId: z.string().uuid() }).parse(input))
190
- .handler(async ({ data }) => {
84
+ .handler(async () => {
191
85
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
192
- const { data: ev, error: evErr } = await supabaseAdmin
193
- .from("email_auth_events")
194
- .select("id, connection_id, external_id, payload, received_at, consumed_at")
195
- .eq("id", data.eventId)
86
+ const { data } = await supabaseAdmin
87
+ .from("mailbox_ingest_runs")
88
+ .select("id, trigger, started_at, finished_at, processed, inserted, errors, error_sample")
89
+ .order("started_at", { ascending: false })
90
+ .limit(1)
196
91
  .maybeSingle();
197
- if (evErr)
198
- throw new Error(evErr.message);
199
- if (!ev)
200
- return { ok: false, error: "Event niet gevonden" };
201
- if (ev.consumed_at)
202
- return { ok: true, inserted: 0, autoProcessed: 0, autoApproved: 0 };
203
- const res = await ingestSingleEvent(supabaseAdmin, ev.connection_id, ev);
204
- await supabaseAdmin
205
- .from("email_auth_events")
206
- .update({ consumed_at: new Date().toISOString() })
207
- .eq("id", ev.id);
208
- // AI-verwerking uitgeschakeld — wacht op externe model-koppeling
209
- const autoProcessed = 0;
210
- const autoApproved = 0;
211
- return { ok: true, inserted: res.inserted ? 1 : 0, autoProcessed, autoApproved };
92
+ return { run: data ?? null };
212
93
  });
213
- // ─── Backfill: haal alle nog niet-consumed events op voor 'orders/receive' ───
214
- export const backfillRecentEvents = createServerFn({ method: "POST" })
94
+ /** Handmatig één voorstel opnieuw laten analyseren met de huidige AI-agent. */
95
+ export const reparseProposalNow = createServerFn({ method: "POST" })
215
96
  .middleware([requireSupabaseAuth])
216
- .handler(async ({ context }) => {
97
+ .inputValidator((input) => z.object({ proposalId: z.string().uuid() }).parse(input))
98
+ .handler(async ({ data }) => {
217
99
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
218
- // Welke connection is gekoppeld aan orders/receive?
219
- const { data: active, error: actErr } = await context.supabase.rpc("email_auth_for_purpose", {
220
- _purpose: "orders",
221
- _direction: "receive",
222
- });
223
- if (actErr)
224
- return { ok: false, error: actErr.message };
225
- const row = Array.isArray(active) ? active[0] : null;
226
- if (!row?.id)
227
- return { ok: false, error: "Geen actieve koppeling voor orders/receive" };
228
- const { data: events, error: evErr } = await supabaseAdmin
229
- .from("email_auth_events")
230
- .select("id, connection_id, external_id, payload, received_at")
231
- .eq("connection_id", row.id)
232
- .is("consumed_at", null)
233
- .order("received_at", { ascending: true })
234
- .limit(200);
235
- if (evErr)
236
- return { ok: false, error: evErr.message };
237
- let inserted = 0;
238
- const consumedIds = [];
239
- for (const ev of events ?? []) {
240
- try {
241
- const res = await ingestSingleEvent(supabaseAdmin, ev.connection_id, ev);
242
- if (res.inserted)
243
- inserted++;
244
- consumedIds.push(ev.id);
245
- }
246
- catch {
247
- // Fout → niet markeren als consumed, volgende ronde opnieuw proberen
248
- }
249
- }
250
- if (consumedIds.length > 0) {
251
- await supabaseAdmin
252
- .from("email_auth_events")
253
- .update({ consumed_at: new Date().toISOString() })
254
- .in("id", consumedIds);
255
- }
256
- // Maak ook stubs voor eerder ontvangen berichten zonder voorstel
257
- const { data: orphanMsgs } = await supabaseAdmin
258
- .from("mailbox_messages")
259
- .select("id, from_email, mailbox_order_proposals(id)")
260
- .order("received_at", { ascending: false })
261
- .limit(200);
262
- let stubbed = 0;
263
- for (const m of (orphanMsgs ?? [])) {
264
- if (Array.isArray(m.mailbox_order_proposals) && m.mailbox_order_proposals.length > 0)
265
- continue;
266
- await createStubProposal(supabaseAdmin, m.id, m.from_email ?? null);
267
- stubbed++;
268
- }
269
- return { ok: true, inserted: inserted + stubbed, autoProcessed: 0, autoApproved: 0 };
100
+ const { reparseProposal } = await import("../lib/mailbox-ingest.server");
101
+ return reparseProposal(supabaseAdmin, data.proposalId);
102
+ });
103
+ /** Detecteert een gewijzigde AI-agent en herverwerkt openstaande voorstellen. */
104
+ export const refreshProposalsForAgentChange = createServerFn({ method: "POST" })
105
+ .middleware([requireSupabaseAuth])
106
+ .handler(async () => {
107
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
108
+ const { reparseChangedAgentProposals } = await import("../lib/mailbox-ingest.server");
109
+ return reparseChangedAgentProposals(supabaseAdmin);
270
110
  });
271
111
  // ─── Messages & Proposals ────────────────────────────────────────────────────
272
112
  export const listMessages = createServerFn({ method: "GET" })
273
113
  .middleware([requireSupabaseAuth])
274
- .handler(async ({ context }) => {
275
- const { data, error } = await context.supabase
114
+ .handler(async () => {
115
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
116
+ const { data, error } = await supabaseAdmin
276
117
  .from("mailbox_messages")
277
118
  .select("id, uid, from_email, from_name, subject, received_at, status, error_message, created_at")
278
119
  .order("received_at", { ascending: false })
@@ -286,6 +127,7 @@ export const parseMessage = createServerFn({ method: "POST" })
286
127
  .inputValidator((input) => z.object({ messageId: z.string().uuid() }).parse(input))
287
128
  .handler(async ({ data }) => {
288
129
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
130
+ const { createStubProposal } = await import("../lib/mailbox-ingest.server");
289
131
  const { data: msg, error: msgErr } = await supabaseAdmin
290
132
  .from("mailbox_messages").select("*").eq("id", data.messageId).single();
291
133
  if (msgErr || !msg)
@@ -299,41 +141,41 @@ export const parseMessage = createServerFn({ method: "POST" })
299
141
  export const listProposals = createServerFn({ method: "GET" })
300
142
  .middleware([requireSupabaseAuth])
301
143
  .inputValidator((input) => z.object({ status: z.enum(["pending", "approved", "rejected"]).optional() }).parse(input ?? {}))
302
- .handler(async ({ data, context }) => {
303
- // Beperk tot berichten die binnenkwamen via de actieve orders/receive mailbox
304
- const { data: active } = await context.supabase.rpc("email_auth_for_purpose", {
305
- _purpose: "orders",
306
- _direction: "receive",
307
- });
308
- const activeRow = Array.isArray(active) ? active[0] : null;
309
- const activeConnectionId = activeRow?.id ?? null;
310
- if (!activeConnectionId)
311
- return { items: [] };
312
- // Stap 1: message-ids van de actieve mailbox
313
- const { data: msgs, error: msgErr } = await context.supabase
314
- .from("mailbox_messages")
315
- .select("id")
316
- .eq("connection_id", activeConnectionId)
317
- .order("received_at", { ascending: false })
318
- .limit(500);
319
- if (msgErr)
320
- throw new Error(msgErr.message);
321
- const messageIds = (msgs ?? []).map((m) => m.id);
322
- if (messageIds.length === 0)
323
- return { items: [] };
324
- // Stap 2: voorstellen op die berichten
325
- let q = context.supabase
144
+ .handler(async ({ data }) => {
145
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
146
+ // Alle voorstellen uit de database, ongeacht via welke mailbox ze binnenkwamen.
147
+ // Bewust server-side: mailbox-voorstellen mogen niet rechtstreeks door andere
148
+ // browsermodules als verkooporders worden gelezen.
149
+ let q = supabaseAdmin
326
150
  .from("mailbox_order_proposals")
327
- .select("*, mailbox_messages(subject, from_email, from_name, body_text, received_at, connection_id), mailbox_proposal_lines(*), mailbox_proposal_field_values(*)")
328
- .in("message_id", messageIds)
151
+ .select("*, mailbox_messages(subject, from_email, from_name, body_text, body_html, received_at, connection_id), mailbox_proposal_lines(*), mailbox_proposal_field_values(*)")
329
152
  .order("created_at", { ascending: false })
330
- .limit(100);
153
+ .limit(500);
331
154
  if (data.status)
332
155
  q = q.eq("status", data.status);
333
156
  const { data: rows, error } = await q;
334
157
  if (error)
335
158
  throw new Error(error.message);
336
- return { items: rows ?? [] };
159
+ const items = (Array.isArray(rows) ? rows : []).map((r) => {
160
+ const m = r?.mailbox_messages;
161
+ if (!m)
162
+ return r;
163
+ const { body_html, ...rest } = m;
164
+ return { ...r, mailbox_messages: { ...rest, body_text: emailBodyText(m.body_text, body_html) } };
165
+ });
166
+ return { items };
167
+ });
168
+ // ─── Ladingdragers uit de Logistics Engine ─────────────────────────────────
169
+ // Nooit cachen: de instelling kan op elk moment wijzigen.
170
+ export const listAllowedLoadCarriers = createServerFn({ method: "GET" })
171
+ .middleware([requireSupabaseAuth])
172
+ .handler(async ({ context }) => {
173
+ const { data, error } = await context.supabase.rpc("logistics_allowed_load_carriers", {
174
+ p_strategy_id: undefined,
175
+ });
176
+ if (error)
177
+ return { ...EMPTY_LOAD_CARRIERS, error: error.message };
178
+ return { ...normalizeAllowedLoadCarriers(data), error: null };
337
179
  });
338
180
  const LineEdit = z.object({
339
181
  id: z.string().uuid().optional(),
@@ -363,12 +205,29 @@ export const approveProposal = createServerFn({ method: "POST" })
363
205
  (data.fieldValues ?? []).forEach((fv) => {
364
206
  customFields[fv.field_key] = fv.value;
365
207
  });
208
+ // ─── Ladingdrager: uitsluitend wat de Engine toestaat ───────────────────
209
+ const { data: lcRaw, error: lcErr } = await context.supabase.rpc("logistics_allowed_load_carriers", { p_strategy_id: undefined });
210
+ if (lcErr)
211
+ throw new Error(`Ladingdragers ophalen mislukt: ${lcErr.message}`);
212
+ const lcList = normalizeAllowedLoadCarriers(lcRaw);
213
+ const requestedCarrier = typeof customFields["load_carrier"] === "string" && customFields["load_carrier"]
214
+ ? customFields["load_carrier"]
215
+ : null;
216
+ const loadCarrierType = requestedCarrier ?? lcList.default;
217
+ if (!isLoadCarrierAllowed(lcList, loadCarrierType)) {
218
+ throw new Error(`Ladingdrager "${loadCarrierLabel(lcList, loadCarrierType) ?? "onbekend"}" is uitgeschakeld in de Rule Engine — bestelling niet aangemaakt.`);
219
+ }
220
+ customFields["load_carrier"] = loadCarrierType;
366
221
  // ─── Validatie eerst mogelijke leverdatum ────────────────────────────────
367
222
  const carrierId = typeof customFields["carrier"] === "string" ? customFields["carrier"] : null;
368
223
  let carrierDeliveryDays = 0;
224
+ let carrierSchedule = null;
369
225
  if (carrierId) {
370
226
  const { data: c } = await context.supabase
371
- .from("carriers").select("delivery_days").eq("id", carrierId).maybeSingle();
227
+ .from("carriers")
228
+ .select("delivery_days, pickup_weekdays, delivery_weekdays, pickup_moments")
229
+ .eq("id", carrierId).maybeSingle();
230
+ carrierSchedule = c ?? null;
372
231
  carrierDeliveryDays = Math.max(0, Number(c?.delivery_days ?? 0));
373
232
  }
374
233
  const { data: settings } = await context.supabase
@@ -377,10 +236,45 @@ export const approveProposal = createServerFn({ method: "POST" })
377
236
  .order("updated_at", { ascending: false })
378
237
  .limit(1).maybeSingle();
379
238
  const pickDays = Math.max(0, Number(settings?.pick_days_before_transport ?? 0));
380
- const earliest = addCalendarDays(new Date(), pickDays + carrierDeliveryDays);
381
- const earliestISO = toISODate(earliest);
239
+ const earliestISO = computeEarliestDelivery(carrierSchedule, pickDays).iso;
382
240
  if (data.deliveryDate < earliestISO) {
383
- throw new Error(`Leverdatum (${data.deliveryDate}) ligt voor de eerst mogelijke datum (${earliestISO}) o.b.v. orderpicktijd (${pickDays}d) + levertijd vervoerder (${carrierDeliveryDays}d).`);
241
+ throw new Error(`Leverdatum (${data.deliveryDate}) ligt voor het eerstvolgende aanmeldmoment van de vervoerder (${earliestISO}).`);
242
+ }
243
+ // ─── Voorraadcontrole door de Logistics Engine (nooit zelf beslissen) ────
244
+ const checkLines = data.lines
245
+ .filter((l) => l.product_id)
246
+ .map((l) => ({
247
+ productId: l.product_id,
248
+ quantity: Math.max(1, Math.round(l.quantity ?? 1)),
249
+ loadCarrierType,
250
+ }));
251
+ let shortages = [];
252
+ if (checkLines.length > 0) {
253
+ const { data: check, error: checkErr } = await context.supabase.rpc("logistics_check_order_allowed", { p_lines: checkLines });
254
+ if (checkErr)
255
+ throw new Error(`Voorraadcontrole mislukt: ${checkErr.message}`);
256
+ const result = (check ?? {});
257
+ shortages = Array.isArray(result.shortages) ? result.shortages : [];
258
+ if (result.allowed === false) {
259
+ const detail = shortages
260
+ .map((s) => `${s.product ?? s.productId}: besteld ${s.wanted}, beschikbaar ${s.available}, tekort ${s.missing}`)
261
+ .join("\n");
262
+ const blockText = `Geblokkeerd - onvoldoende voorraad\n${detail}`;
263
+ await supabaseAdmin
264
+ .from("mailbox_order_proposals")
265
+ .update({ notes: blockText })
266
+ .eq("id", data.proposalId);
267
+ const { data: blockedProp } = await supabaseAdmin
268
+ .from("mailbox_order_proposals")
269
+ .select("message_id").eq("id", data.proposalId).maybeSingle();
270
+ if (blockedProp?.message_id) {
271
+ await supabaseAdmin
272
+ .from("mailbox_messages")
273
+ .update({ status: "blocked_insufficient_stock" })
274
+ .eq("id", blockedProp.message_id);
275
+ }
276
+ throw new Error(blockText);
277
+ }
384
278
  }
385
279
  const orderNumber = `MB-${Date.now()}`;
386
280
  const { data: order, error: orderErr } = await supabaseAdmin
@@ -429,7 +323,26 @@ export const approveProposal = createServerFn({ method: "POST" })
429
323
  if (prop?.message_id) {
430
324
  await supabaseAdmin.from("mailbox_messages").update({ status: "converted" }).eq("id", prop.message_id);
431
325
  }
432
- return { ok: true, orderId: order.id };
326
+ // ─── Gebeurtenis melden aan de Logistics Engine ─────────────────────────
327
+ const deliveryLocationRef = typeof customFields["delivery_location"] === "string" ? customFields["delivery_location"] : null;
328
+ await context.supabase.rpc("logistics_emit_event", {
329
+ p_source_module: "mailbox",
330
+ p_event_type: "order.received",
331
+ p_order_ref: orderNumber,
332
+ p_payload: {
333
+ checkinAt: data.deliveryDate,
334
+ pickupAt: data.deliveryDate,
335
+ customerRef: data.customerId,
336
+ deliveryLocationRef,
337
+ bufferRef: null,
338
+ loadCarrierType,
339
+ loadCarriers: [loadCarrierType],
340
+ ruleVersion: null,
341
+ },
342
+ p_shadow: true,
343
+ p_idempotency_key: `mailbox:${orderNumber}:order.received:v1`,
344
+ });
345
+ return { ok: true, orderId: order.id, orderNumber, shortages };
433
346
  });
434
347
  export const rejectProposal = createServerFn({ method: "POST" })
435
348
  .middleware([requireSupabaseAuth])
@@ -457,20 +370,207 @@ export const reopenProposal = createServerFn({ method: "POST" })
457
370
  }).eq("id", data.proposalId);
458
371
  return { ok: true };
459
372
  });
373
+ // Klanten voor de picker — gecombineerde lijst uit `external_customers_grouped`
374
+ // (lokaal + Twinfield + Floriday). Rijen met een lokale bron krijgen het
375
+ // bestaande `customers.id`; rijen zonder lokale bron krijgen een tijdelijke
376
+ // sleutel `grp:<group_key>` en moeten eerst als klant worden aangemaakt.
460
377
  export const listCustomersForPicker = createServerFn({ method: "GET" })
461
378
  .middleware([requireSupabaseAuth])
462
- .handler(async ({ context }) => {
463
- const { data } = await context.supabase
464
- .from("customers").select("id, company_name, email").eq("is_active", true).order("company_name").range(0, 4999);
465
- return { items: data ?? [] };
379
+ .handler(async () => {
380
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
381
+ const sb = supabaseAdmin;
382
+ const { data: grouped } = await sb
383
+ .from("external_customers_grouped")
384
+ .select("group_key, display_name, email, city, country, gln, providers, sources, is_active")
385
+ .range(0, 4999);
386
+ const rows = Array.isArray(grouped) ? grouped : [];
387
+ const items = rows
388
+ .filter((r) => r?.is_active !== false && (r?.display_name ?? "").trim() !== "")
389
+ .map((r) => {
390
+ const sources = Array.isArray(r.sources) ? r.sources : [];
391
+ const local = sources.find((s) => s?.provider === "local" && typeof s?.external_id === "string");
392
+ const best = local ?? sources[0] ?? {};
393
+ const providers = Array.isArray(r.providers) ? r.providers : [];
394
+ return {
395
+ id: local?.external_id ?? `grp:${r.group_key}`,
396
+ group_key: r.group_key,
397
+ is_local: Boolean(local),
398
+ company_name: (r.display_name ?? best.company_name ?? "Onbekend"),
399
+ email: (r.email ?? best.email ?? null),
400
+ city: (r.city ?? best.city ?? null),
401
+ country: (r.country ?? best.country ?? null),
402
+ gln: (r.gln ?? best.gln ?? null),
403
+ street: (best.street ?? null),
404
+ house_number: (best.house_number ?? null),
405
+ postal_code: (best.postal_code ?? null),
406
+ phone: (best.phone ?? null),
407
+ providers: providers,
408
+ };
409
+ })
410
+ .sort((a, b) => a.company_name.localeCompare(b.company_name, "nl"));
411
+ if (items.length > 0)
412
+ return { items };
413
+ // Terugval: als de gecombineerde view leeg is, toch de lokale klanten tonen.
414
+ const { data: fallback } = await sb
415
+ .from("customers").select("id, company_name, email, city, country")
416
+ .eq("is_active", true).order("company_name").range(0, 4999);
417
+ return {
418
+ items: (Array.isArray(fallback) ? fallback : []).map((c) => ({
419
+ ...c, group_key: `local:${c.id}`, is_local: true, providers: ["local"],
420
+ gln: null, street: null, house_number: null, postal_code: null, phone: null,
421
+ })),
422
+ };
423
+ });
424
+ // Nieuwe klant aanmaken vanuit een externe bron. De gebruiker bevestigt de
425
+ // velden zelf in de UI — er wordt nooit stilzwijgend een klant aangemaakt.
426
+ export const createCustomerFromGroup = createServerFn({ method: "POST" })
427
+ .middleware([requireSupabaseAuth])
428
+ .inputValidator((input) => z.object({
429
+ groupKey: z.string().min(1),
430
+ companyName: z.string().min(1).max(255),
431
+ email: z.string().email().nullable().optional(),
432
+ street: z.string().max(255).nullable().optional(),
433
+ houseNumber: z.string().max(50).nullable().optional(),
434
+ postalCode: z.string().max(50).nullable().optional(),
435
+ city: z.string().max(255).nullable().optional(),
436
+ country: z.string().max(255).nullable().optional(),
437
+ gln: z.string().max(50).nullable().optional(),
438
+ phone: z.string().max(50).nullable().optional(),
439
+ }).parse(input))
440
+ .handler(async ({ data }) => {
441
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
442
+ const sb = supabaseAdmin;
443
+ const email = (data.email ?? "").trim().toLowerCase() || null;
444
+ const name = data.companyName.trim();
445
+ // Bestaat er al een lokale klant met dit e-mailadres of deze naam?
446
+ if (email) {
447
+ const { data: byEmail } = await sb.from("customers")
448
+ .select("id").ilike("email", email).limit(1).maybeSingle();
449
+ if (byEmail?.id)
450
+ return { id: byEmail.id, created: false };
451
+ }
452
+ const { data: byName } = await sb.from("customers")
453
+ .select("id").ilike("company_name", name).limit(1).maybeSingle();
454
+ if (byName?.id)
455
+ return { id: byName.id, created: false };
456
+ const { data: inserted, error } = await sb.from("customers").insert({
457
+ company_name: name,
458
+ email,
459
+ street: data.street ?? null,
460
+ house_number: data.houseNumber ?? null,
461
+ postal_code: data.postalCode ?? null,
462
+ city: data.city ?? null,
463
+ country: data.country ?? null,
464
+ gln: data.gln ?? null,
465
+ phone: data.phone ?? null,
466
+ is_active: true,
467
+ custom_fields: { created_from_group_key: data.groupKey },
468
+ }).select("id").single();
469
+ if (error)
470
+ throw new Error(error.message);
471
+ return { id: inserted.id, created: true };
466
472
  });
467
- // Producten voor de picker — alleen items met beschikbare voorraad volgens de
468
- // Floriday Voorraad Module (`trade_item_stock`). Beschikbaar = physical - reserved.
469
- // De koppeling loopt via `products.custom_fields.floriday_id` = `trade_item_stock.trade_item_key`.
473
+ // Producten voor de picker — ALLE artikelen uit de voorraad:
474
+ // 1. alles in `products`
475
+ // 2. elk handelsitem uit `floriday_trade_items_cache` (Voorraad Module) dat nog
476
+ // geen products-rij heeft, wordt hier automatisch aangemaakt (id = floriday_id)
477
+ // zodat bestelregels, belading en orders er gewoon naar kunnen verwijzen
478
+ // Voorraad komt uit `trade_item_stock` en `trade_item_stock_view`.
470
479
  export const listProductsForPicker = createServerFn({ method: "GET" })
471
480
  .middleware([requireSupabaseAuth])
472
481
  .handler(async ({ context }) => {
473
- const { data: stockRows } = await context.supabase
482
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
483
+ // ── 1. Handelsitems uit de Voorraad Module materialiseren ───────────────
484
+ const { data: cacheRows } = await supabaseAdmin
485
+ .from("floriday_trade_items_cache")
486
+ .select("floriday_id, is_deleted, data")
487
+ .range(0, 4999);
488
+ const tradeItems = (Array.isArray(cacheRows) ? cacheRows : []).filter((r) => !r?.is_deleted && typeof r?.floriday_id === "string");
489
+ const { data: existingRows } = await supabaseAdmin
490
+ .from("products")
491
+ .select("id, custom_fields, floriday_trade_item_id")
492
+ .range(0, 9999);
493
+ const existing = Array.isArray(existingRows) ? existingRows : [];
494
+ const known = new Set();
495
+ for (const p of existing) {
496
+ known.add(String(p.id));
497
+ const fid = p?.custom_fields?.floriday_id ?? p?.floriday_trade_item_id;
498
+ if (typeof fid === "string" && fid)
499
+ known.add(fid);
500
+ }
501
+ const norm = (v) => Math.max(1, Math.round(Number(v ?? 1)) || 1);
502
+ const nameOf = (d) => {
503
+ const n = d?.tradeItemName;
504
+ if (typeof n === "string" && n.trim())
505
+ return n.trim();
506
+ if (n && typeof n === "object") {
507
+ const v = n.nl ?? n.en ?? Object.values(n).find((x) => typeof x === "string");
508
+ if (typeof v === "string" && v.trim())
509
+ return v.trim();
510
+ }
511
+ return typeof d?.tradeItemReference === "string" && d.tradeItemReference
512
+ ? d.tradeItemReference
513
+ : "Onbekend artikel";
514
+ };
515
+ const missing = tradeItems.filter((r) => !known.has(r.floriday_id));
516
+ const buildRow = (r) => {
517
+ const d = r.data ?? {};
518
+ const pk = Array.isArray(d.packingConfigurations) ? d.packingConfigurations[0] ?? {} : {};
519
+ return {
520
+ id: r.floriday_id,
521
+ product: nameOf(d),
522
+ // batch + location zijn NOT NULL zonder default in de Voorraad Module
523
+ batch: "",
524
+ location: "",
525
+ unit: "stuks",
526
+ quantity: 0,
527
+ plants_per_crate: norm(pk?.piecesPerPackage ?? pk?.unitsPerPackage),
528
+ crates_per_layer: norm(pk?.packagesPerLayer),
529
+ layers_per_cart: norm(pk?.layersPerLoadCarrier),
530
+ floriday_trade_item_id: r.floriday_id,
531
+ article_number: typeof d?.supplierArticleCode === "string" ? d.supplierArticleCode : null,
532
+ custom_fields: { floriday_id: r.floriday_id, source: "floriday_trade_items_cache" },
533
+ };
534
+ };
535
+ // Concept-artikelen uit de Voorraad Module (nog niet in Floriday) ook tonen
536
+ const { data: draftRowsAll } = await supabaseAdmin
537
+ .from("trade_item_drafts")
538
+ .select("id, data, floriday_id")
539
+ .range(0, 4999);
540
+ const draftMissing = (Array.isArray(draftRowsAll) ? draftRowsAll : []).filter((d) => typeof d?.id === "string" && !known.has(d.id) &&
541
+ !(typeof d?.floriday_id === "string" && known.has(d.floriday_id)));
542
+ const buildDraftRow = (r) => {
543
+ const d = r.data ?? {};
544
+ const pk = Array.isArray(d.packingConfigurations) ? d.packingConfigurations[0] ?? {} : {};
545
+ return {
546
+ id: r.id,
547
+ product: nameOf(d),
548
+ batch: "",
549
+ location: "",
550
+ unit: "stuks",
551
+ quantity: 0,
552
+ plants_per_crate: norm(pk?.unitsPerPackage ?? pk?.piecesPerPackage),
553
+ crates_per_layer: norm(pk?.packagesPerLayer),
554
+ layers_per_cart: norm(pk?.layersPerLoadCarrier),
555
+ article_number: typeof d?.supplierArticleCode === "string" ? d.supplierArticleCode : null,
556
+ custom_fields: { draft_id: r.id, source: "trade_item_drafts" },
557
+ };
558
+ };
559
+ const toInsert = [...missing.map(buildRow), ...draftMissing.map(buildDraftRow)];
560
+ // Artikelen die (nog) niet in `products` staan, tonen we hoe dan ook —
561
+ // ook als materialiseren faalt.
562
+ let virtualRows = [];
563
+ if (toInsert.length > 0) {
564
+ const { error: upsertError } = await supabaseAdmin
565
+ .from("products")
566
+ .upsert(toInsert, { onConflict: "id" });
567
+ if (upsertError) {
568
+ console.error("[listProductsForPicker] materialiseren mislukt:", upsertError.message);
569
+ virtualRows = toInsert;
570
+ }
571
+ }
572
+ // ── 2. Voorraad verzamelen ──────────────────────────────────────────────
573
+ const { data: stockRows } = await supabaseAdmin
474
574
  .from("trade_item_stock")
475
575
  .select("trade_item_key, physical_stock, reserved_stock");
476
576
  const availByProductId = new Map();
@@ -499,12 +599,14 @@ export const listProductsForPicker = createServerFn({ method: "GET" })
499
599
  }
500
600
  // Drafts uit de Voorraad Module: koppel via linkedProductId → products.id
501
601
  if (availByDraftId.size > 0) {
502
- const { data: drafts } = await context.supabase
602
+ const { data: drafts } = await supabaseAdmin
503
603
  .from("trade_item_drafts")
504
604
  .select("id, data, floriday_id")
505
605
  .in("id", Array.from(availByDraftId.keys()));
506
606
  for (const d of Array.isArray(drafts) ? drafts : []) {
507
607
  const qty = availByDraftId.get(d.id) ?? 0;
608
+ // Concepten worden ook als product gematerialiseerd op hun eigen id
609
+ addAvail(availByProductId, d.id, qty);
508
610
  const linked = d?.data?.linkedProductId;
509
611
  if (typeof linked === "string" && linked)
510
612
  addAvail(availByProductId, linked, qty);
@@ -513,19 +615,76 @@ export const listProductsForPicker = createServerFn({ method: "GET" })
513
615
  addAvail(availByFloridayId, fid, qty);
514
616
  }
515
617
  }
516
- const { data } = await context.supabase
618
+ // Voorraadoverzicht van de Voorraad Module (view)
619
+ try {
620
+ const { data: viewRows } = await supabaseAdmin
621
+ .from("trade_item_stock_view")
622
+ .select("trade_item_id, stock");
623
+ for (const v of Array.isArray(viewRows) ? viewRows : []) {
624
+ const id = v?.trade_item_id;
625
+ if (typeof id !== "string")
626
+ continue;
627
+ if (!availByFloridayId.has(id) && !availByProductId.has(id)) {
628
+ availByFloridayId.set(id, Number(v?.stock ?? 0));
629
+ }
630
+ }
631
+ }
632
+ catch {
633
+ // view optioneel
634
+ }
635
+ // ── 3. Alle producten teruggeven ────────────────────────────────────────
636
+ const { data } = await supabaseAdmin
517
637
  .from("products")
518
- .select("id, product, unit, plants_per_crate, crates_per_layer, layers_per_cart, custom_fields")
519
- .order("product").range(0, 4999);
520
- const items = (Array.isArray(data) ? data : [])
638
+ .select("id, product, unit, plants_per_crate, crates_per_layer, layers_per_cart, custom_fields, floriday_trade_item_id")
639
+ .order("product")
640
+ .range(0, 9999);
641
+ const dbRows = Array.isArray(data) ? data : [];
642
+ const seen = new Set(dbRows.map((p) => String(p.id)));
643
+ const allRows = [
644
+ ...dbRows,
645
+ ...virtualRows.filter((v) => !seen.has(String(v.id))),
646
+ ];
647
+ const items = allRows
521
648
  .map((p) => {
522
- const fid = p?.custom_fields?.floriday_id;
649
+ const fid = p?.custom_fields?.floriday_id ?? p?.floriday_trade_item_id;
523
650
  const byProd = availByProductId.get(p.id) ?? 0;
524
651
  const byFid = typeof fid === "string" ? (availByFloridayId.get(fid) ?? 0) : 0;
525
- const available_stock = byProd + byFid;
526
- return { ...p, available_stock };
527
- });
528
- return { items };
652
+ return { ...p, available_stock: byProd + byFid };
653
+ })
654
+ .sort((a, b) => String(a.product ?? "").localeCompare(String(b.product ?? "")));
655
+ // ── 4. Rule Engine: mag er doorverkocht worden zonder voorraad? ─────────
656
+ // `logistics_rule_configs.settings.order_verwerking.fields.allow_backorder`
657
+ // bepaalt of artikelen zonder voorraad selecteerbaar blijven. Er is niet
658
+ // altijd een config met is_active = true; val dan terug op de laatst
659
+ // bijgewerkte werkwijze zodat de instelling toch wordt gevolgd.
660
+ let allowBackorder = false;
661
+ try {
662
+ const readFlag = (rows) => {
663
+ const cfg = (Array.isArray(rows) ? rows : [])[0];
664
+ const raw = cfg?.settings?.order_verwerking?.fields?.allow_backorder;
665
+ return raw === undefined ? undefined : raw === true || raw === "true";
666
+ };
667
+ const { data: activeRows } = await supabaseAdmin
668
+ .from("logistics_rule_configs")
669
+ .select("settings, is_active, updated_at")
670
+ .eq("is_active", true)
671
+ .order("updated_at", { ascending: false })
672
+ .limit(1);
673
+ let flag = readFlag(activeRows);
674
+ if (flag === undefined) {
675
+ const { data: latestRows } = await supabaseAdmin
676
+ .from("logistics_rule_configs")
677
+ .select("settings, updated_at")
678
+ .order("updated_at", { ascending: false })
679
+ .limit(1);
680
+ flag = readFlag(latestRows);
681
+ }
682
+ allowBackorder = flag === true;
683
+ }
684
+ catch (e) {
685
+ console.error("[listProductsForPicker] rule engine lezen mislukt:", e);
686
+ }
687
+ return { items, allowBackorder };
529
688
  });
530
689
  export const planAndSaveAutoCarts = createServerFn({ method: "POST" })
531
690
  .middleware([requireSupabaseAuth])
@@ -731,9 +890,14 @@ export const listPickupLocations = createServerFn({ method: "GET" })
731
890
  const label = nameRaw.length > 0
732
891
  ? nameRaw
733
892
  : [addr, city].filter(Boolean).join(", ") || "(naamloos)";
734
- return { id: r.floriday_id, name: label, sort_order: 0 };
893
+ return {
894
+ id: r.floriday_id,
895
+ name: label,
896
+ is_default: r?.data?.isDefault === true,
897
+ sort_order: 0,
898
+ };
735
899
  })
736
- .sort((a, b) => a.name.localeCompare(b.name, "nl"));
900
+ .sort((a, b) => (a.is_default === b.is_default ? a.name.localeCompare(b.name, "nl") : a.is_default ? -1 : 1));
737
901
  return { items };
738
902
  });
739
903
  export const listCarriersForPicker = createServerFn({ method: "GET" })
@@ -741,7 +905,7 @@ export const listCarriersForPicker = createServerFn({ method: "GET" })
741
905
  .handler(async ({ context }) => {
742
906
  const { data } = await context.supabase
743
907
  .from("carriers")
744
- .select("id, name, is_default, sort_order, delivery_days")
908
+ .select("id, name, is_default, sort_order, delivery_days, pickup_weekdays, delivery_weekdays, pickup_moments")
745
909
  .order("sort_order", { ascending: true, nullsFirst: false })
746
910
  .order("name");
747
911
  return { items: data ?? [] };
@@ -766,3 +930,206 @@ function addCalendarDays(base, n) {
766
930
  function toISODate(d) {
767
931
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
768
932
  }
933
+ // ─── Beladingen per product ────────────────────────────────────────────────
934
+ // Zelfde bronnen als de Floriday Verkoop-module: packing-configuraties uit de
935
+ // trade item cache, concept-artikelen, groepsleden en de products-tabel
936
+ // (inclusief de opgeslagen Floriday-payload). Daarnaast worden ALLE in het
937
+ // systeem bekende beladingen meegegeven, ook de niet-gekoppelde.
938
+ export const listBeladingOptionsForProducts = createServerFn({ method: "POST" })
939
+ .middleware([requireSupabaseAuth])
940
+ .inputValidator((input) => z.object({ productIds: z.array(z.string()).max(200) }).parse(input))
941
+ .handler(async ({ data }) => {
942
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
943
+ const ids = Array.from(new Set((data.productIds ?? []).filter(Boolean)));
944
+ const byProduct = {};
945
+ function sortCombos(list) {
946
+ return [...list]
947
+ .sort((a, b) => a.plants_per_crate - b.plants_per_crate ||
948
+ a.crates_per_layer - b.crates_per_layer ||
949
+ a.layers_per_cart - b.layers_per_cart)
950
+ .map((v) => ({
951
+ ...v,
952
+ crates_per_cart: v.crates_per_layer * v.layers_per_cart,
953
+ plants_per_cart: v.plants_per_crate * v.crates_per_layer * v.layers_per_cart,
954
+ }));
955
+ }
956
+ // ── Namen van fusten (VBN-verpakkingscodes) en karren (load carriers) ───
957
+ const CARRIER_NAMES = {
958
+ DANISH_TROLLEY: "Deense container",
959
+ DANISH_CONTAINER: "Deense container",
960
+ DANISH_TROLLEY_NON_CC_RFID: "Deense container (non-CC RFID)",
961
+ DANISH_TROLLEY_WATER_TRAY: "Deense container met waterschaal",
962
+ ONE_THIRD_DANISH_TROLLEY: "1/3 Deense container",
963
+ AUCTION_TROLLEY: "Veilingstapelwagen",
964
+ EURO_TROLLEY: "Euro-stapelwagen",
965
+ CC_CONTAINER: "CC-container",
966
+ PALLET: "Pallet",
967
+ EURO_PALLET: "Europallet",
968
+ BOX: "Doos",
969
+ NONE: "Losse fusten",
970
+ };
971
+ const { data: floricodeRows } = await supabaseAdmin
972
+ .from("floricode_records")
973
+ .select("data")
974
+ .eq("resource", "LoadCarrier")
975
+ .range(0, 4999);
976
+ const crateNameByCode = new Map();
977
+ for (const r of Array.isArray(floricodeRows) ? floricodeRows : []) {
978
+ const d = r?.data;
979
+ if (d?.code == null)
980
+ continue;
981
+ const descr = typeof d?.description === "string" ? d.description.trim() : "";
982
+ if (descr)
983
+ crateNameByCode.set(String(d.code), descr);
984
+ }
985
+ const crateInfo = (p) => {
986
+ const raw = p?.package?.vbnPackageCode ?? p?.vbnPackageCode ?? p?.package?.customPackageId ?? null;
987
+ const code = raw == null || raw === "" ? null : String(raw);
988
+ return { code, name: code ? crateNameByCode.get(code) ?? `Fust ${code}` : null };
989
+ };
990
+ const carrierInfo = (p) => {
991
+ const raw = typeof p?.loadCarrier === "string" ? p.loadCarrier : (p?.loadCarrier?.type ?? null);
992
+ const code = typeof raw === "string" && raw ? raw : null;
993
+ return { code, name: code ? CARRIER_NAMES[code.toUpperCase()] ?? code : null };
994
+ };
995
+ const norm = (v) => Math.max(1, Math.round(Number(v ?? 1)) || 1);
996
+ const emptyMeta = { crate_code: null, crate_name: null, carrier_code: null, carrier_name: null };
997
+ const sameCombo = (x, a, b, c, m) => x.plants_per_crate === a && x.crates_per_layer === b && x.layers_per_cart === c &&
998
+ (x.crate_code ?? null) === (m.crate_code ?? null) &&
999
+ (x.carrier_code ?? null) === (m.carrier_code ?? null);
1000
+ const packsById = new Map();
1001
+ const push = (key, ppc, cpl, lpc, isDefault, meta = emptyMeta) => {
1002
+ const a = norm(ppc), b = norm(cpl), c = norm(lpc);
1003
+ const arr = packsById.get(key) ?? [];
1004
+ const hit = arr.find((x) => sameCombo(x, a, b, c, meta));
1005
+ if (hit) {
1006
+ if (isDefault)
1007
+ hit.is_default = true;
1008
+ return;
1009
+ }
1010
+ arr.push({ plants_per_crate: a, crates_per_layer: b, layers_per_cart: c, is_default: isDefault, ...meta });
1011
+ packsById.set(key, arr);
1012
+ };
1013
+ const pushPack = (key, p, ppc, isDefault) => {
1014
+ const cr = crateInfo(p), ca = carrierInfo(p);
1015
+ push(key, ppc, p?.packagesPerLayer, p?.layersPerLoadCarrier, isDefault, {
1016
+ crate_code: cr.code, crate_name: cr.name, carrier_code: ca.code, carrier_name: ca.name,
1017
+ });
1018
+ };
1019
+ // ── Alle beladingen in het systeem (ook niet-gekoppeld) ─────────────────
1020
+ const globalCombos = new Map();
1021
+ const addGlobal = (ppc, cpl, lpc, meta = emptyMeta) => {
1022
+ const a = norm(ppc), b = norm(cpl), c = norm(lpc);
1023
+ if (a === 1 && b === 1 && c === 1)
1024
+ return;
1025
+ const k = `${a}x${b}x${c}|${meta.crate_code ?? ""}|${meta.carrier_code ?? ""}`;
1026
+ if (globalCombos.has(k))
1027
+ return;
1028
+ globalCombos.set(k, { plants_per_crate: a, crates_per_layer: b, layers_per_cart: c, is_default: false, ...meta });
1029
+ };
1030
+ const addGlobalPack = (p, ppc) => {
1031
+ const cr = crateInfo(p), ca = carrierInfo(p);
1032
+ addGlobal(ppc, p?.packagesPerLayer, p?.layersPerLoadCarrier, {
1033
+ crate_code: cr.code, crate_name: cr.name, carrier_code: ca.code, carrier_name: ca.name,
1034
+ });
1035
+ };
1036
+ const addAllPackings = (d, fn) => {
1037
+ const packs = Array.isArray(d?.packingConfigurations) ? d.packingConfigurations : [];
1038
+ for (const p of packs)
1039
+ fn(p);
1040
+ };
1041
+ const [allTiRes, allDraftRes, allProdRes] = await Promise.all([
1042
+ supabaseAdmin.from("floriday_trade_items_cache").select("data").range(0, 4999),
1043
+ supabaseAdmin.from("trade_item_drafts").select("data").range(0, 4999),
1044
+ supabaseAdmin
1045
+ .from("products")
1046
+ .select("plants_per_crate, crates_per_layer, layers_per_cart, custom_fields")
1047
+ .range(0, 9999),
1048
+ ]);
1049
+ for (const r of Array.isArray(allTiRes.data) ? allTiRes.data : []) {
1050
+ addAllPackings(r?.data, (p) => addGlobalPack(p, p?.piecesPerPackage ?? p?.unitsPerPackage));
1051
+ }
1052
+ for (const r of Array.isArray(allDraftRes.data) ? allDraftRes.data : []) {
1053
+ addAllPackings(r?.data, (p) => addGlobalPack(p, p?.unitsPerPackage ?? p?.piecesPerPackage));
1054
+ }
1055
+ for (const p of Array.isArray(allProdRes.data) ? allProdRes.data : []) {
1056
+ addGlobal(p.plants_per_crate, p.crates_per_layer, p.layers_per_cart);
1057
+ addAllPackings(p?.custom_fields?.floriday_payload, (pc) => addGlobalPack(pc, pc?.unitsPerPackage ?? pc?.piecesPerPackage));
1058
+ }
1059
+ // Verwijder "kale" varianten (zonder fust-/kartype) als dezelfde afmetingen
1060
+ // ook met fust- en kartype bekend zijn — anders zie je dubbele regels.
1061
+ const dims = (c) => `${c.plants_per_crate}x${c.crates_per_layer}x${c.layers_per_cart}`;
1062
+ const dropBare = (list) => {
1063
+ const rich = new Set(list.filter((c) => c.crate_code || c.carrier_code).map(dims));
1064
+ return list.filter((c) => c.crate_code || c.carrier_code || !rich.has(dims(c)));
1065
+ };
1066
+ const globalList = dropBare(Array.from(globalCombos.values()));
1067
+ if (!ids.length)
1068
+ return { byProduct, allBeladingen: sortCombos(globalList) };
1069
+ // ── Gekoppelde beladingen per product ───────────────────────────────────
1070
+ const { data: giRows } = await supabaseAdmin
1071
+ .from("product_group_items")
1072
+ .select("product_group_id, trade_item_id")
1073
+ .in("product_group_id", ids);
1074
+ const membersByGroup = new Map();
1075
+ for (const gi of Array.isArray(giRows) ? giRows : []) {
1076
+ const gid = gi.product_group_id;
1077
+ const arr = membersByGroup.get(gid) ?? [];
1078
+ arr.push(gi.trade_item_id);
1079
+ membersByGroup.set(gid, arr);
1080
+ }
1081
+ const lookupIds = Array.from(new Set([...ids, ...Array.from(membersByGroup.values()).flat()]));
1082
+ const [tiRes, draftRes, prodRes] = await Promise.all([
1083
+ supabaseAdmin.from("floriday_trade_items_cache").select("floriday_id, data").in("floriday_id", lookupIds),
1084
+ supabaseAdmin.from("trade_item_drafts").select("id, data").in("id", lookupIds),
1085
+ supabaseAdmin
1086
+ .from("products")
1087
+ .select("id, plants_per_crate, crates_per_layer, layers_per_cart, custom_fields, trade_item_key")
1088
+ .in("id", lookupIds),
1089
+ ]);
1090
+ for (const r of Array.isArray(tiRes.data) ? tiRes.data : []) {
1091
+ const key = r.floriday_id;
1092
+ addAllPackings(r?.data, (p) => pushPack(key, p, p?.piecesPerPackage ?? p?.unitsPerPackage, !!(p?.primary || p?.isPrimary)));
1093
+ }
1094
+ for (const r of Array.isArray(draftRes.data) ? draftRes.data : []) {
1095
+ const key = r.id;
1096
+ addAllPackings(r?.data, (p) => pushPack(key, p, p?.unitsPerPackage ?? p?.piecesPerPackage, !!(p?.primary || p?.isPrimary)));
1097
+ }
1098
+ for (const p of Array.isArray(prodRes.data) ? prodRes.data : []) {
1099
+ const key = p.id;
1100
+ push(key, p.plants_per_crate, p.crates_per_layer, p.layers_per_cart, true);
1101
+ const fid = p.custom_fields?.floriday_id;
1102
+ if (fid)
1103
+ push(fid, p.plants_per_crate, p.crates_per_layer, p.layers_per_cart, false);
1104
+ const tk = p.trade_item_key;
1105
+ if (typeof tk === "string" && tk.startsWith("group:")) {
1106
+ push(tk.slice("group:".length), p.plants_per_crate, p.crates_per_layer, p.layers_per_cart, true);
1107
+ }
1108
+ addAllPackings(p?.custom_fields?.floriday_payload, (pc) => {
1109
+ const isPrim = !!(pc?.isPrimary || pc?.primary);
1110
+ const ppc = pc?.unitsPerPackage ?? pc?.piecesPerPackage;
1111
+ pushPack(key, pc, ppc, isPrim);
1112
+ if (fid)
1113
+ pushPack(fid, pc, ppc, isPrim);
1114
+ });
1115
+ }
1116
+ for (const id of ids) {
1117
+ const own = packsById.get(id) ?? [];
1118
+ const memberCombos = [];
1119
+ for (const m of membersByGroup.get(id) ?? []) {
1120
+ for (const c of packsById.get(m) ?? [])
1121
+ memberCombos.push(c);
1122
+ }
1123
+ const merged = [];
1124
+ for (const c of [...own, ...memberCombos, ...globalList]) {
1125
+ if (merged.some((x) => sameCombo(x, c.plants_per_crate, c.crates_per_layer, c.layers_per_cart, c)))
1126
+ continue;
1127
+ merged.push({ ...c, is_default: c.is_default && own.includes(c) });
1128
+ }
1129
+ const cleaned = dropBare(merged);
1130
+ if (cleaned.length && !cleaned.some((x) => x.is_default))
1131
+ cleaned[0].is_default = true;
1132
+ byProduct[id] = sortCombos(cleaned);
1133
+ }
1134
+ return { byProduct, allBeladingen: sortCombos(globalList) };
1135
+ });