@flowselections/mailbox-orders 1.0.2 → 1.0.4

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 (35) hide show
  1. package/dist-lib/components/CartAssignmentWizard.d.ts +6 -0
  2. package/dist-lib/components/CartAssignmentWizard.d.ts.map +1 -0
  3. package/dist-lib/components/CartAssignmentWizard.js +199 -0
  4. package/dist-lib/components/MailboxInboxPage.d.ts.map +1 -1
  5. package/dist-lib/components/MailboxInboxPage.js +78 -19
  6. package/dist-lib/components/MailboxProcessedPage.d.ts.map +1 -1
  7. package/dist-lib/components/MailboxProcessedPage.js +25 -4
  8. package/dist-lib/components/MailboxProposalsPage.d.ts.map +1 -1
  9. package/dist-lib/components/MailboxProposalsPage.js +272 -34
  10. package/dist-lib/components/SearchableSelect.d.ts +2 -1
  11. package/dist-lib/components/SearchableSelect.d.ts.map +1 -1
  12. package/dist-lib/components/SearchableSelect.js +4 -3
  13. package/dist-lib/index.d.ts +0 -1
  14. package/dist-lib/index.d.ts.map +1 -1
  15. package/dist-lib/index.js +3 -7
  16. package/dist-lib/integrations/supabase/auth-attacher.d.ts.map +1 -1
  17. package/dist-lib/integrations/supabase/auth-attacher.js +37 -3
  18. package/dist-lib/integrations/supabase/auth-middleware.d.ts +7662 -1413
  19. package/dist-lib/integrations/supabase/auth-middleware.d.ts.map +1 -1
  20. package/dist-lib/integrations/supabase/client.d.ts +7662 -1413
  21. package/dist-lib/integrations/supabase/client.d.ts.map +1 -1
  22. package/dist-lib/integrations/supabase/client.server.d.ts +7662 -1413
  23. package/dist-lib/integrations/supabase/client.server.d.ts.map +1 -1
  24. package/dist-lib/integrations/supabase/types.d.ts +7837 -1406
  25. package/dist-lib/integrations/supabase/types.d.ts.map +1 -1
  26. package/dist-lib/integrations/supabase/types.js +20 -1
  27. package/dist-lib/lib/mailbox-ai.server.d.ts +1 -1
  28. package/dist-lib/lib/mailbox-ai.server.d.ts.map +1 -1
  29. package/dist-lib/lib/mailbox-ai.server.js +6 -106
  30. package/dist-lib/lib/mailbox-template.functions.d.ts +49077 -11583
  31. package/dist-lib/lib/mailbox-template.functions.d.ts.map +1 -1
  32. package/dist-lib/lib/mailbox.functions.d.ts +143316 -19105
  33. package/dist-lib/lib/mailbox.functions.d.ts.map +1 -1
  34. package/dist-lib/lib/mailbox.functions.js +536 -173
  35. package/package.json +4 -8
@@ -1,85 +1,272 @@
1
1
  // Server functions voor de Mailbox-bestellingen module
2
2
  import { createServerFn } from "@tanstack/react-start";
3
3
  import { requireSupabaseAuth } from "../integrations/supabase/auth-middleware";
4
+ import { createClient } from "@supabase/supabase-js";
4
5
  import { z } from "zod";
5
- // ─── Settings ────────────────────────────────────────────────────────────────
6
- export const getMailboxSettings = createServerFn({ method: "GET" })
7
- .middleware([requireSupabaseAuth])
8
- .handler(async ({ context }) => {
9
- const { data, error } = await context.supabase
10
- .from("mailbox_settings")
11
- .select("*")
12
- .limit(1)
13
- .maybeSingle();
6
+ // ─── Actieve mailbox (uit gedeelde e-mail-auth module) ──────────────────────
7
+ // Geen auth-middleware: de RPC is SECURITY DEFINER en EXECUTE staat ook aan
8
+ // `anon`. Zo blijft de status-banner werken als de bearer-token tijdelijk
9
+ // ontbreekt of verlopen is — anders crasht de UI met een 401.
10
+ export const getActiveOrdersMailbox = createServerFn({ method: "GET" })
11
+ .handler(async () => {
12
+ const url = process.env.SUPABASE_URL;
13
+ const key = process.env.SUPABASE_PUBLISHABLE_KEY;
14
+ if (!url || !key)
15
+ return { connected: false, error: "missing_env" };
16
+ const sb = createClient(url, key, {
17
+ auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
18
+ });
19
+ const { data, error } = await sb.rpc("email_auth_for_purpose", {
20
+ _purpose: "orders",
21
+ _direction: "receive",
22
+ });
14
23
  if (error)
15
- throw new Error(error.message);
16
- return { settings: data };
17
- });
18
- const SettingsInput = z.object({
19
- imap_host: z.string().max(255),
20
- imap_port: z.number().int().min(1).max(65535),
21
- imap_username: z.string().max(255),
22
- imap_use_tls: z.boolean(),
23
- folder: z.string().max(255),
24
- polling_enabled: z.boolean(),
24
+ return { connected: false, error: error.message };
25
+ const row = Array.isArray(data) ? data[0] : null;
26
+ if (!row)
27
+ return { connected: false };
28
+ return {
29
+ connected: true,
30
+ id: row.id,
31
+ provider: row.provider,
32
+ account_email: row.account_email,
33
+ display_name: (row.display_name ?? null),
34
+ status: (row.status ?? null),
35
+ last_error: (row.last_error ?? null),
36
+ last_verified_at: (row.last_verified_at ?? null),
37
+ needs_reauth: !!row.needs_reauth,
38
+ };
25
39
  });
26
- export const saveMailboxSettings = createServerFn({ method: "POST" })
27
- .middleware([requireSupabaseAuth])
28
- .inputValidator((input) => SettingsInput.parse(input))
29
- .handler(async ({ data, context }) => {
30
- const { data: existing } = await context.supabase
31
- .from("mailbox_settings").select("id").limit(1).maybeSingle();
32
- if (existing?.id) {
33
- const { error } = await context.supabase
34
- .from("mailbox_settings").update(data).eq("id", existing.id);
35
- if (error)
36
- throw new Error(error.message);
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 };
61
+ }
37
62
  }
38
- else {
39
- const { error } = await context.supabase
40
- .from("mailbox_settings").insert({ ...data, singleton: true });
41
- if (error)
42
- throw new Error(error.message);
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 };
89
+ }
90
+ throw new Error(insErr.message);
43
91
  }
44
- return { ok: true };
45
- });
46
- async function invokeMailboxImap(supabase, action) {
47
- const { data, error } = await supabase.functions.invoke("mailbox-imap", {
48
- body: { action },
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
+ },
49
116
  });
50
- if (error)
51
- throw new Error(error.message ?? "Edge function aanroep mislukt");
52
- return data;
53
117
  }
54
- export const testMailboxConnection = createServerFn({ method: "POST" })
55
- .middleware([requireSupabaseAuth])
56
- .handler(async ({ context }) => {
57
- try {
58
- return await invokeMailboxImap(context.supabase, "test");
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: [] };
59
162
  }
60
- catch (e) {
61
- return { ok: false, error: e?.message ?? "Onbekende fout" };
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) {
172
+ return {
173
+ customerId: uniqueCustomerIds[0],
174
+ source: "customer_contacts",
175
+ warning: null,
176
+ candidates: cands,
177
+ };
62
178
  }
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" })
188
+ .middleware([requireSupabaseAuth])
189
+ .inputValidator((input) => z.object({ eventId: z.string().uuid() }).parse(input))
190
+ .handler(async ({ data }) => {
191
+ 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)
196
+ .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 };
63
212
  });
64
- // ─── Polling ─────────────────────────────────────────────────────────────────
65
- export const pollMailbox = createServerFn({ method: "POST" })
213
+ // ─── Backfill: haal alle nog niet-consumed events op voor 'orders/receive' ───
214
+ export const backfillRecentEvents = createServerFn({ method: "POST" })
66
215
  .middleware([requireSupabaseAuth])
67
216
  .handler(async ({ context }) => {
68
- try {
69
- const res = await invokeMailboxImap(context.supabase, "poll");
70
- let autoProcessed = 0;
71
- let autoApproved = 0;
72
- if (res.ok && (res.inserted ?? 0) > 0) {
73
- const { autoProcessNewMessagesInternal } = await import("./mailbox-auto.server");
74
- const r = await autoProcessNewMessagesInternal();
75
- autoProcessed = r.processed;
76
- autoApproved = r.approved;
217
+ 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
77
248
  }
78
- return { ok: !!res.ok, inserted: res.inserted ?? 0, autoProcessed, autoApproved, error: res.error };
79
249
  }
80
- catch (e) {
81
- return { ok: false, error: e?.message ?? "Onbekende fout", inserted: 0 };
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++;
82
268
  }
269
+ return { ok: true, inserted: inserted + stubbed, autoProcessed: 0, autoApproved: 0 };
83
270
  });
84
271
  // ─── Messages & Proposals ────────────────────────────────────────────────────
85
272
  export const listMessages = createServerFn({ method: "GET" })
@@ -103,120 +290,42 @@ export const parseMessage = createServerFn({ method: "POST" })
103
290
  .from("mailbox_messages").select("*").eq("id", data.messageId).single();
104
291
  if (msgErr || !msg)
105
292
  throw new Error("Bericht niet gevonden");
293
+ // AI staat uit: maak een leeg voorstel zodat het bericht in "Voorstellen" verschijnt.
294
+ await createStubProposal(supabaseAdmin, msg.id, msg.from_email ?? null);
106
295
  await supabaseAdmin.from("mailbox_messages")
107
- .update({ status: "parsing", error_message: null }).eq("id", msg.id);
108
- try {
109
- // Templatevelden ophalen
110
- const { data: fields } = await supabaseAdmin
111
- .from("mailbox_order_template_fields")
112
- .select("*")
113
- .order("sort_order", { ascending: true });
114
- const templateFields = (fields ?? []).filter((f) => f.ai_enabled);
115
- // Databron-catalogi
116
- const [{ data: products }, { data: customers }, { data: suppliers }] = await Promise.all([
117
- supabaseAdmin.from("products").select("id, product, product_type").order("product").range(0, 1999),
118
- supabaseAdmin.from("customers").select("id, company_name, email").eq("is_active", true).order("company_name").range(0, 4999),
119
- supabaseAdmin.from("suppliers").select("id, name").order("name").range(0, 999),
120
- ]);
121
- const catalog = (products ?? []).map((p) => ({ id: p.id, name: p.product }));
122
- const plantTypeSet = new Set();
123
- (products ?? []).forEach((p) => { if (p.product_type)
124
- plantTypeSet.add(p.product_type); });
125
- const plantTypes = Array.from(plantTypeSet).sort().map((v) => ({ id: v, name: v }));
126
- // Bouw spec met allowed values per database-veld
127
- const spec = templateFields.map((f) => {
128
- let allowed;
129
- if (f.source === "database") {
130
- switch (f.field_type) {
131
- case "customer":
132
- allowed = (customers ?? []).map((c) => ({ id: c.id, name: c.company_name }));
133
- break;
134
- case "product":
135
- allowed = catalog;
136
- break;
137
- case "plant_type":
138
- allowed = plantTypes;
139
- break;
140
- case "supplier":
141
- allowed = (suppliers ?? []).map((s) => ({ id: s.id, name: s.name }));
142
- break;
143
- }
144
- }
145
- return {
146
- key: f.key,
147
- label: f.label,
148
- field_type: f.field_type,
149
- ai_hint: f.ai_hint,
150
- allowed,
151
- };
152
- });
153
- const { parseOrderEmail } = await import("./mailbox-ai.server");
154
- const parsed = await parseOrderEmail(msg.body_text ?? msg.body_html ?? "", msg.subject ?? "", spec, catalog);
155
- // Backwards-compat: leid klant/leverdatum/notes af voor de bestaande kolommen
156
- const customerId = parsed.fields["customer"]?.value;
157
- const deliveryDate = parsed.fields["delivery_date"]?.value;
158
- const notesValue = parsed.fields["notes"]?.value;
159
- const matchedCustomerId = typeof customerId === "string" && customerId.length === 36 ? customerId : null;
160
- // Fallback: match op e-mail als AI niets vond
161
- let finalCustomerId = matchedCustomerId;
162
- if (!finalCustomerId && msg.from_email) {
163
- const { data: customer } = await supabaseAdmin
164
- .from("customers").select("id").eq("email", msg.from_email).maybeSingle();
165
- finalCustomerId = customer?.id ?? null;
166
- }
167
- const { data: proposal, error: propErr } = await supabaseAdmin
168
- .from("mailbox_order_proposals").insert({
169
- message_id: msg.id,
170
- matched_customer_id: finalCustomerId,
171
- confidence: parsed.overall_confidence,
172
- delivery_date: typeof deliveryDate === "string" ? deliveryDate : null,
173
- notes: typeof notesValue === "string" ? notesValue : null,
174
- parsed_payload: parsed,
175
- status: "pending",
176
- }).select("id").single();
177
- if (propErr)
178
- throw new Error(propErr.message);
179
- // Sla alle veldwaarden op
180
- const fieldRows = Object.entries(parsed.fields).map(([key, v]) => ({
181
- proposal_id: proposal.id,
182
- field_key: key,
183
- value: v.value === undefined ? null : v.value,
184
- ai_confidence: v.confidence,
185
- ai_filled: v.value !== null && v.value !== undefined,
186
- needs_review: v.needs_review,
187
- }));
188
- if (fieldRows.length > 0) {
189
- await supabaseAdmin.from("mailbox_proposal_field_values").insert(fieldRows);
190
- }
191
- if (parsed.lines.length > 0) {
192
- const linesToInsert = parsed.lines.map((l, idx) => ({
193
- proposal_id: proposal.id,
194
- product_id: l.product_id,
195
- raw_product_text: l.raw_product_text,
196
- quantity: l.quantity,
197
- unit: l.unit,
198
- match_confidence: l.match_confidence,
199
- sort_order: idx,
200
- }));
201
- await supabaseAdmin.from("mailbox_proposal_lines").insert(linesToInsert);
202
- }
203
- await supabaseAdmin.from("mailbox_messages")
204
- .update({ status: "parsed" }).eq("id", msg.id);
205
- return { ok: true, proposalId: proposal.id };
206
- }
207
- catch (e) {
208
- await supabaseAdmin.from("mailbox_messages")
209
- .update({ status: "failed", error_message: e?.message ?? "Parsefout" }).eq("id", msg.id);
210
- return { ok: false, error: e?.message ?? "Parsefout" };
211
- }
296
+ .update({ status: "parsed", error_message: null }).eq("id", msg.id);
297
+ return { ok: true };
212
298
  });
213
299
  export const listProposals = createServerFn({ method: "GET" })
214
300
  .middleware([requireSupabaseAuth])
215
301
  .inputValidator((input) => z.object({ status: z.enum(["pending", "approved", "rejected"]).optional() }).parse(input ?? {}))
216
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
217
325
  let q = context.supabase
218
326
  .from("mailbox_order_proposals")
219
- .select("*, mailbox_messages(subject, from_email, from_name, body_text, received_at), mailbox_proposal_lines(*), mailbox_proposal_field_values(*)")
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)
220
329
  .order("created_at", { ascending: false })
221
330
  .limit(100);
222
331
  if (data.status)
@@ -250,11 +359,29 @@ export const approveProposal = createServerFn({ method: "POST" })
250
359
  }).parse(input))
251
360
  .handler(async ({ data, context }) => {
252
361
  const { supabaseAdmin } = await import("../integrations/supabase/client.server");
253
- // Bouw custom_fields map vanuit alle templatevelden
254
362
  const customFields = {};
255
363
  (data.fieldValues ?? []).forEach((fv) => {
256
364
  customFields[fv.field_key] = fv.value;
257
365
  });
366
+ // ─── Validatie eerst mogelijke leverdatum ────────────────────────────────
367
+ const carrierId = typeof customFields["carrier"] === "string" ? customFields["carrier"] : null;
368
+ let carrierDeliveryDays = 0;
369
+ if (carrierId) {
370
+ const { data: c } = await context.supabase
371
+ .from("carriers").select("delivery_days").eq("id", carrierId).maybeSingle();
372
+ carrierDeliveryDays = Math.max(0, Number(c?.delivery_days ?? 0));
373
+ }
374
+ const { data: settings } = await context.supabase
375
+ .from("afhandeling_settings")
376
+ .select("pick_days_before_transport")
377
+ .order("updated_at", { ascending: false })
378
+ .limit(1).maybeSingle();
379
+ 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);
382
+ 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).`);
384
+ }
258
385
  const orderNumber = `MB-${Date.now()}`;
259
386
  const { data: order, error: orderErr } = await supabaseAdmin
260
387
  .from("orders").insert({
@@ -280,7 +407,6 @@ export const approveProposal = createServerFn({ method: "POST" })
280
407
  const { error: itemsErr } = await supabaseAdmin.from("order_items").insert(items);
281
408
  if (itemsErr)
282
409
  throw new Error(itemsErr.message);
283
- // Sla finale veldwaarden op (overschrijft AI-waarden)
284
410
  if (data.fieldValues && data.fieldValues.length > 0) {
285
411
  for (const fv of data.fieldValues) {
286
412
  await supabaseAdmin.from("mailbox_proposal_field_values").upsert({
@@ -318,6 +444,19 @@ export const rejectProposal = createServerFn({ method: "POST" })
318
444
  }).eq("id", data.proposalId);
319
445
  return { ok: true };
320
446
  });
447
+ // Een afgewezen (of goedgekeurd) voorstel terugzetten naar "te controleren".
448
+ export const reopenProposal = createServerFn({ method: "POST" })
449
+ .middleware([requireSupabaseAuth])
450
+ .inputValidator((input) => z.object({ proposalId: z.string().uuid() }).parse(input))
451
+ .handler(async ({ data }) => {
452
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
453
+ await supabaseAdmin.from("mailbox_order_proposals").update({
454
+ status: "pending",
455
+ reviewed_by: null,
456
+ reviewed_at: null,
457
+ }).eq("id", data.proposalId);
458
+ return { ok: true };
459
+ });
321
460
  export const listCustomersForPicker = createServerFn({ method: "GET" })
322
461
  .middleware([requireSupabaseAuth])
323
462
  .handler(async ({ context }) => {
@@ -329,6 +468,230 @@ export const listProductsForPicker = createServerFn({ method: "GET" })
329
468
  .middleware([requireSupabaseAuth])
330
469
  .handler(async ({ context }) => {
331
470
  const { data } = await context.supabase
332
- .from("products").select("id, product, unit").order("product").range(0, 4999);
471
+ .from("products")
472
+ .select("id, product, unit, plants_per_crate, crates_per_layer, layers_per_cart")
473
+ .order("product").range(0, 4999);
333
474
  return { items: data ?? [] };
334
475
  });
476
+ export const planAndSaveAutoCarts = createServerFn({ method: "POST" })
477
+ .middleware([requireSupabaseAuth])
478
+ .inputValidator((input) => z.object({ orderId: z.string().uuid() }).parse(input))
479
+ .handler(async ({ data, context }) => {
480
+ // Items met productbelading ophalen
481
+ const { data: items, error: itErr } = await context.supabase
482
+ .from("order_items")
483
+ .select("id, product_id, product_name, quantity, products(plants_per_crate, crates_per_layer, layers_per_cart)")
484
+ .eq("order_id", data.orderId);
485
+ if (itErr)
486
+ throw new Error(itErr.message);
487
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
488
+ // Reset eventuele eerdere auto-karren (idempotent)
489
+ await supabaseAdmin.from("order_carts").delete().eq("order_id", data.orderId).eq("is_auto", true);
490
+ let cartNumber = 1;
491
+ // Eerst hoogste bestaande nr ophalen (voor manueel toegevoegde karren)
492
+ const { data: existingMax } = await supabaseAdmin
493
+ .from("order_carts").select("cart_number").eq("order_id", data.orderId)
494
+ .order("cart_number", { ascending: false }).limit(1);
495
+ if (existingMax && existingMax[0])
496
+ cartNumber = existingMax[0].cart_number + 1;
497
+ const autoCarts = [];
498
+ const remainders = [];
499
+ for (const it of items ?? []) {
500
+ const prod = it.products ?? {};
501
+ const ppc = Math.max(1, Number(prod.plants_per_crate ?? 1));
502
+ const cpl = Math.max(1, Number(prod.crates_per_layer ?? 1));
503
+ const lpc = Math.max(1, Number(prod.layers_per_cart ?? 1));
504
+ const cratesPerCart = cpl * lpc;
505
+ // quantity is in PLANTEN (zie approveProposal)
506
+ const totalPlants = Math.max(0, Number(it.quantity ?? 0));
507
+ const totalCrates = Math.ceil(totalPlants / ppc);
508
+ const fullCarts = Math.floor(totalCrates / cratesPerCart);
509
+ const remainder = totalCrates - fullCarts * cratesPerCart;
510
+ for (let i = 0; i < fullCarts; i++) {
511
+ const { data: cart, error: cErr } = await supabaseAdmin
512
+ .from("order_carts").insert({
513
+ order_id: data.orderId,
514
+ cart_number: cartNumber,
515
+ is_auto: true,
516
+ is_complete: true,
517
+ }).select("id").single();
518
+ if (cErr)
519
+ throw new Error(cErr.message);
520
+ await supabaseAdmin.from("order_cart_items").insert({
521
+ cart_id: cart.id,
522
+ order_item_id: it.id,
523
+ product_id: it.product_id,
524
+ product_name: it.product_name,
525
+ crates: cratesPerCart,
526
+ plants_per_crate: ppc,
527
+ crates_per_layer: cpl,
528
+ });
529
+ autoCarts.push({
530
+ cart_number: cartNumber,
531
+ product_name: it.product_name,
532
+ crates: cratesPerCart,
533
+ plants: cratesPerCart * ppc,
534
+ capacity: cratesPerCart,
535
+ });
536
+ cartNumber++;
537
+ }
538
+ if (remainder > 0) {
539
+ remainders.push({
540
+ order_item_id: it.id,
541
+ product_id: it.product_id,
542
+ product_name: it.product_name,
543
+ crates_remaining: remainder,
544
+ plants_per_crate: ppc,
545
+ crates_per_layer: cpl,
546
+ layers_per_cart: lpc,
547
+ });
548
+ }
549
+ }
550
+ // Bestaande handmatige karren ophalen + crates per order_item aftrekken van remainders,
551
+ // zodat heropenen van de wizard niet dezelfde fusten opnieuw vraagt.
552
+ const { data: manualCartsRaw } = await supabaseAdmin
553
+ .from("order_carts")
554
+ .select("id, cart_number, notes, order_cart_items(order_item_id, product_id, product_name, crates, plants_per_crate, crates_per_layer)")
555
+ .eq("order_id", data.orderId).eq("is_auto", false)
556
+ .order("cart_number");
557
+ const existingManualCarts = (manualCartsRaw ?? []).map((c) => ({
558
+ cart_number: c.cart_number,
559
+ notes: c.notes,
560
+ items: (c.order_cart_items ?? []).map((i) => ({
561
+ order_item_id: i.order_item_id,
562
+ product_id: i.product_id,
563
+ product_name: i.product_name,
564
+ crates: Number(i.crates ?? 0),
565
+ plants_per_crate: Number(i.plants_per_crate ?? 1),
566
+ crates_per_layer: Number(i.crates_per_layer ?? 1),
567
+ })),
568
+ }));
569
+ const assignedByItem = {};
570
+ existingManualCarts.forEach((c) => c.items.forEach((i) => {
571
+ if (!i.order_item_id)
572
+ return;
573
+ assignedByItem[i.order_item_id] = (assignedByItem[i.order_item_id] ?? 0) + Number(i.crates ?? 0);
574
+ }));
575
+ const adjustedRemainders = remainders
576
+ .map((r) => ({ ...r, crates_remaining: Math.max(0, r.crates_remaining - (assignedByItem[r.order_item_id] ?? 0)) }))
577
+ .filter((r) => r.crates_remaining > 0);
578
+ return { ok: true, autoCarts, remainders: adjustedRemainders, existingManualCarts, nextCartNumber: cartNumber };
579
+ });
580
+ const ManualCartItem = z.object({
581
+ order_item_id: z.string().uuid().nullable(),
582
+ product_id: z.string().uuid().nullable(),
583
+ product_name: z.string().min(1).max(255),
584
+ crates: z.number().int().min(1),
585
+ plants_per_crate: z.number().int().min(1),
586
+ crates_per_layer: z.number().int().min(1),
587
+ });
588
+ const ManualCart = z.object({
589
+ is_complete: z.boolean().optional(),
590
+ notes: z.string().max(500).nullable().optional(),
591
+ items: z.array(ManualCartItem).min(1),
592
+ });
593
+ export const saveManualCarts = createServerFn({ method: "POST" })
594
+ .middleware([requireSupabaseAuth])
595
+ .inputValidator((input) => z.object({ orderId: z.string().uuid(), carts: z.array(ManualCart) }).parse(input))
596
+ .handler(async ({ data }) => {
597
+ const { supabaseAdmin } = await import("../integrations/supabase/client.server");
598
+ // Verwijder eerdere handmatige karren (replace strategy)
599
+ await supabaseAdmin.from("order_carts").delete()
600
+ .eq("order_id", data.orderId).eq("is_auto", false);
601
+ const { data: existingMax } = await supabaseAdmin
602
+ .from("order_carts").select("cart_number").eq("order_id", data.orderId)
603
+ .order("cart_number", { ascending: false }).limit(1);
604
+ let cartNumber = existingMax && existingMax[0] ? existingMax[0].cart_number + 1 : 1;
605
+ for (const cart of data.carts) {
606
+ const { data: inserted, error: cErr } = await supabaseAdmin
607
+ .from("order_carts").insert({
608
+ order_id: data.orderId,
609
+ cart_number: cartNumber,
610
+ is_auto: false,
611
+ is_complete: !!cart.is_complete,
612
+ notes: cart.notes ?? null,
613
+ }).select("id").single();
614
+ if (cErr)
615
+ throw new Error(cErr.message);
616
+ const rows = cart.items.map((i) => ({
617
+ cart_id: inserted.id,
618
+ order_item_id: i.order_item_id,
619
+ product_id: i.product_id,
620
+ product_name: i.product_name,
621
+ crates: i.crates,
622
+ plants_per_crate: i.plants_per_crate,
623
+ crates_per_layer: i.crates_per_layer,
624
+ }));
625
+ const { error: iErr } = await supabaseAdmin.from("order_cart_items").insert(rows);
626
+ if (iErr)
627
+ throw new Error(iErr.message);
628
+ cartNumber++;
629
+ }
630
+ return { ok: true };
631
+ });
632
+ export const listOrderCarts = createServerFn({ method: "POST" })
633
+ .middleware([requireSupabaseAuth])
634
+ .inputValidator((d) => d)
635
+ .handler(async ({ data, context }) => {
636
+ const { data: carts } = await context.supabase
637
+ .from("order_carts")
638
+ .select("id, cart_number, is_auto, is_complete, notes, order_cart_items(*)")
639
+ .eq("order_id", data.orderId)
640
+ .order("cart_number");
641
+ return { items: carts ?? [] };
642
+ });
643
+ export const listDeliveryLocationsForCustomer = createServerFn({ method: "POST" })
644
+ .middleware([requireSupabaseAuth])
645
+ .inputValidator((d) => d)
646
+ .handler(async ({ data, context }) => {
647
+ if (!data.customerId)
648
+ return { items: [] };
649
+ const { data: rows } = await context.supabase
650
+ .from("customer_delivery_locations")
651
+ .select("id, name, address_line, street, house_number, postal_code, city, province, country, is_primary, carrier_id, carriers(id, name)")
652
+ .eq("customer_id", data.customerId)
653
+ .order("is_primary", { ascending: false })
654
+ .order("name");
655
+ return { items: Array.isArray(rows) ? rows : [] };
656
+ });
657
+ // Onze eigen bedrijfslocaties (tuinen / afhaallocaties). Staan los van klantlocaties.
658
+ export const listPickupLocations = createServerFn({ method: "GET" })
659
+ .middleware([requireSupabaseAuth])
660
+ .handler(async ({ context }) => {
661
+ const { data } = await context.supabase
662
+ .from("locations")
663
+ .select("id, name, sort_order")
664
+ .order("sort_order", { ascending: true })
665
+ .order("name");
666
+ return { items: Array.isArray(data) ? data : [] };
667
+ });
668
+ export const listCarriersForPicker = createServerFn({ method: "GET" })
669
+ .middleware([requireSupabaseAuth])
670
+ .handler(async ({ context }) => {
671
+ const { data } = await context.supabase
672
+ .from("carriers")
673
+ .select("id, name, is_default, sort_order, delivery_days")
674
+ .order("sort_order", { ascending: true, nullsFirst: false })
675
+ .order("name");
676
+ return { items: data ?? [] };
677
+ });
678
+ // Orderpicktijd — gebruikt voor het berekenen van de eerst mogelijke leverdatum
679
+ export const getOrderPickDays = createServerFn({ method: "GET" })
680
+ .middleware([requireSupabaseAuth])
681
+ .handler(async ({ context }) => {
682
+ const { data } = await context.supabase
683
+ .from("afhandeling_settings")
684
+ .select("pick_days_before_transport")
685
+ .order("updated_at", { ascending: false })
686
+ .limit(1)
687
+ .maybeSingle();
688
+ return { pickDays: Math.max(0, Number(data?.pick_days_before_transport ?? 0)) };
689
+ });
690
+ function addCalendarDays(base, n) {
691
+ const x = new Date(base);
692
+ x.setDate(x.getDate() + n);
693
+ return x;
694
+ }
695
+ function toISODate(d) {
696
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
697
+ }