From 3599ec995f7cb415eaf75e304858ccf9615097e6 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 3 Jul 2026 07:30:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(dispatch):=20P1-C=20"Trouver=20un=20cr?= =?UTF-8?q?=C3=A9neau"=20=E2=80=94=20wire=20suggest-slots=20to=20a=20booki?= =?UTF-8?q?ng=20dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub /dispatch/suggest-slots (gap-finding + travel buffer + proximity ranking) existed but had NO UI (Gemini claimed SuggestSlotsDialog.vue was coded — it did not exist). Built it: - api/dispatch.js: suggestSlots(payload) → POST /dispatch/suggest-slots. - SuggestSlotsDialog.vue: address (RQA geocode via useAddressSearch) + duration + earliest date → ranked slots (tech, date, time, travel min, reasons). Selecting a slot emits it. - DispatchPage: "📅 Créneau" button in the header opens the dialog; onSlotSelected prefills the WO create modal (tech + date + address + duration) and confirmWo injects the slot's start_time + geocoded coords into createJob. Manual "+ WO" clears the booking slot. Verified: dialog compiles/opens with inputs; suggest-slots returns ranked slots (e.g. Gilles Drolet 08:15–09:45, journée libre, 15 min). Note: the address geocode (/api/method/search_address) 403s in the dev preview because the dev proxy doesn't inject the ERP token that prod nginx adds — works in prod (same call the create modal already uses). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/api/dispatch.js | 12 ++ .../components/SuggestSlotsDialog.vue | 107 ++++++++++++++++++ apps/ops/src/pages/DispatchPage.vue | 23 +++- 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue diff --git a/apps/ops/src/api/dispatch.js b/apps/ops/src/api/dispatch.js index bc64096..52ddfee 100644 --- a/apps/ops/src/api/dispatch.js +++ b/apps/ops/src/api/dispatch.js @@ -3,8 +3,20 @@ // Swap BASE_URL in config/erpnext.js to change the target server. // ───────────────────────────────────────────────────────────────────────────── import { BASE_URL } from 'src/config/erpnext' +import { HUB_URL } from 'src/config/hub' import { authFetch } from './auth' +// Créneaux disponibles (hub : gap-finding + tampon de route + classement) pour un nouveau job à une adresse. +// payload = { duration_h, latitude, longitude, after_date?, limit? } → [{ tech_id, tech_name, date, start_time, end_time, travel_min, distance_km, reasons[] }] +export async function suggestSlots (payload) { + const res = await fetch(`${HUB_URL}/dispatch/suggest-slots`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}), + }) + if (!res.ok) throw new Error('suggest-slots ' + res.status) + const data = await res.json() + return data.slots || data || [] +} + async function apiGet (path) { const res = await authFetch(BASE_URL + path) const data = await res.json() diff --git a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue new file mode 100644 index 0000000..0ebcc5d --- /dev/null +++ b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue @@ -0,0 +1,107 @@ + + + diff --git a/apps/ops/src/pages/DispatchPage.vue b/apps/ops/src/pages/DispatchPage.vue index d2b38fa..07d5c2e 100644 --- a/apps/ops/src/pages/DispatchPage.vue +++ b/apps/ops/src/pages/DispatchPage.vue @@ -15,6 +15,7 @@ import TimelineRow from 'src/modules/dispatch/components/TimelineRow.vue' import BottomPanel from 'src/modules/dispatch/components/BottomPanel.vue' import JobEditModal from 'src/modules/dispatch/components/JobEditModal.vue' import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' +import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' import PublishScheduleModal from 'src/modules/dispatch/components/PublishScheduleModal.vue' import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue' import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue' @@ -396,6 +397,8 @@ async function confirmMove () { const bookingOverlay = ref(null) const woModalOpen = ref(false) const woModalCtx = ref({}) +const showSuggestSlots = ref(false) +const bookingSlot = ref(null) // créneau choisi (porte start_time + coords) — injecté dans confirmWo const publishModalOpen = ref(false) // NLP bar is hidden by default; toggled from the ⋯ menu (Assistant IA). // Showing it eagerly bloats the header on narrow laptops and the @@ -823,23 +826,34 @@ async function saveTechGroup (tech, value) { } function openWoModal (prefillDate = null, prefillTech = null) { + bookingSlot.value = null // création manuelle → pas de créneau réservé woModalCtx.value = { scheduled_date: prefillDate || todayStr, assigned_tech: prefillTech || null } woModalOpen.value = true } +// P1-C : un créneau choisi dans « Trouver un créneau » → pré-remplit la création de job (tech + date + adresse), l'heure/coords injectées au submit. +function onSlotSelected (slot) { + bookingSlot.value = slot + woModalCtx.value = { scheduled_date: slot.date, assigned_tech: slot.tech_id, address: slot._address || '', duration_h: slot._duration || 1 } + woModalOpen.value = true +} async function confirmWo (formData) { - return await store.createJob({ + const slot = bookingSlot.value // réservation via « Trouver un créneau » → heure + coords géocodées de l'adresse + const r = await store.createJob({ subject: formData.subject, address: formData.address, duration_h: formData.duration_h, priority: formData.priority, assigned_tech: formData.assigned_tech || null, scheduled_date: formData.scheduled_date || null, - latitude: formData._latitude || null, - longitude: formData._longitude || null, + start_time: slot ? slot.start_time : null, // heure du créneau réservé (sinon non planifié à l'heure) + latitude: formData._latitude || (slot ? slot._latitude : null), + longitude: formData._longitude || (slot ? slot._longitude : null), note: formData.description || '', tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t), depends_on: formData.depends_on || '', }) + bookingSlot.value = null + return r } const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({ @@ -1369,6 +1383,7 @@ onUnmounted(() => { + +