feat(dispatch): P1-C "Trouver un créneau" — wire suggest-slots to a booking dialog

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) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 07:30:08 -04:00
parent 7b7980a12b
commit 3599ec995f
3 changed files with 139 additions and 3 deletions

View File

@ -3,8 +3,20 @@
// Swap BASE_URL in config/erpnext.js to change the target server. // Swap BASE_URL in config/erpnext.js to change the target server.
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub'
import { authFetch } from './auth' 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) { async function apiGet (path) {
const res = await authFetch(BASE_URL + path) const res = await authFetch(BASE_URL + path)
const data = await res.json() const data = await res.json()

View File

@ -0,0 +1,107 @@
<script setup>
/**
* SuggestSlotsDialog « Trouver un créneau » : pour un NOUVEAU job à une adresse,
* interroge le hub (POST /dispatch/suggest-slots : gap-finding par tech + tampon de
* route + classement proximité) et propose les meilleurs créneaux. Le choix émet
* 'select' avec le créneau + l'adresse/coords/durée → l'appelant pré-remplit la
* création de job (tech + date + heure + adresse). Backend déjà en place.
*/
import { reactive, ref } from 'vue'
import { Notify } from 'quasar'
import { suggestSlots } from 'src/api/dispatch'
import { useAddressSearch } from 'src/composables/useAddressSearch'
defineProps({ modelValue: { type: Boolean, default: false } })
const emit = defineEmits(['update:modelValue', 'select'])
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
const target = reactive({ address: '', latitude: null, longitude: null, ville: '' })
const durationH = ref(1)
const afterDate = ref(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }))
const loading = ref(false)
const slots = ref([])
const searched = ref(false)
function onPickAddr (addr) { selectAddr(addr, target) }
async function search () {
if (target.latitude == null || target.longitude == null) {
Notify.create({ type: 'warning', message: 'Choisis une adresse (pour situer les créneaux les plus proches).', timeout: 3000 })
return
}
loading.value = true; searched.value = true
try {
slots.value = await suggestSlots({
duration_h: durationH.value, latitude: target.latitude, longitude: target.longitude,
after_date: afterDate.value, limit: 8,
})
} catch (e) {
Notify.create({ type: 'negative', message: 'Recherche de créneaux impossible : ' + (e.message || e), timeout: 4000 })
slots.value = []
} finally { loading.value = false }
}
function pick (s) {
emit('select', { ...s, _address: target.address, _latitude: target.latitude, _longitude: target.longitude, _duration: durationH.value })
emit('update:modelValue', false)
}
const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
</script>
<template>
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)">
<q-card style="width:520px;max-width:95vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="event_available" color="primary" size="24px" class="q-mr-sm" />
<div class="text-h6">Trouver un créneau</div>
<q-space /><q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
</q-card-section>
<q-card-section class="q-gutter-sm">
<div class="text-caption text-grey-7">Saisis l'adresse du client + la durée : on classe les meilleurs créneaux (tech le plus proche, trou dans l'horaire, moins de route).</div>
<q-input dense outlined v-model="target.address" label="Adresse du client" clearable
@update:model-value="searchAddr" :loading="addrLoading" autofocus>
<template #prepend><q-icon name="place" /></template>
<q-menu v-if="addrResults.length" no-focus fit>
<q-list dense style="min-width:340px;max-height:40vh;overflow:auto">
<q-item v-for="(a, i) in addrResults" :key="i" clickable v-close-popup @click="onPickAddr(a)">
<q-item-section>{{ a.address_full }}</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-input>
<div class="row q-col-gutter-sm">
<q-input class="col" dense outlined type="number" v-model.number="durationH" label="Durée (h)" :min="0.5" :max="8" :step="0.5">
<template #prepend><q-icon name="schedule" /></template>
</q-input>
<q-input class="col" dense outlined type="date" v-model="afterDate" label="À partir du">
<template #prepend><q-icon name="event" /></template>
</q-input>
</div>
<q-btn unelevated color="primary" icon="search" label="Chercher des créneaux" class="full-width"
:loading="loading" :disable="target.latitude == null" @click="search" no-caps />
</q-card-section>
<q-separator v-if="searched" />
<q-card-section v-if="searched" style="max-height:46vh;overflow:auto" class="q-pt-sm">
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /></div>
<div v-else-if="!slots.length" class="text-grey-6 text-center q-pa-md">Aucun créneau disponible sur l'horizon élargis la date ou libère un tech.</div>
<q-list v-else separator>
<q-item v-for="(s, i) in slots" :key="i" clickable @click="pick(s)" class="rounded-borders">
<q-item-section avatar>
<q-avatar size="34px" color="blue-grey-1" text-color="blue-grey-8" class="text-weight-bold" style="font-size:12px">{{ dayLabel(s.date).slice(0, 3) }}</q-avatar>
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium">{{ dayLabel(s.date) }} · {{ s.start_time }}{{ s.end_time }}</q-item-label>
<q-item-label caption>{{ s.tech_name }}<span v-if="s.travel_min"> · 🚗 {{ s.travel_min }} min<span v-if="s.distance_km"> ({{ s.distance_km }} km)</span></span></q-item-label>
<q-item-label caption class="text-grey-6">{{ (s.reasons || []).join(' · ') }}</q-item-label>
</q-item-section>
<q-item-section side><q-icon name="chevron_right" color="primary" /></q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</q-dialog>
</template>

View File

@ -15,6 +15,7 @@ import TimelineRow from 'src/modules/dispatch/components/TimelineRow.vue'
import BottomPanel from 'src/modules/dispatch/components/BottomPanel.vue' import BottomPanel from 'src/modules/dispatch/components/BottomPanel.vue'
import JobEditModal from 'src/modules/dispatch/components/JobEditModal.vue' import JobEditModal from 'src/modules/dispatch/components/JobEditModal.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.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 PublishScheduleModal from 'src/modules/dispatch/components/PublishScheduleModal.vue'
import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue' import WeekCalendar from 'src/modules/dispatch/components/WeekCalendar.vue'
import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue' import MonthCalendar from 'src/modules/dispatch/components/MonthCalendar.vue'
@ -396,6 +397,8 @@ async function confirmMove () {
const bookingOverlay = ref(null) const bookingOverlay = ref(null)
const woModalOpen = ref(false) const woModalOpen = ref(false)
const woModalCtx = ref({}) 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) const publishModalOpen = ref(false)
// NLP bar is hidden by default; toggled from the menu (Assistant IA). // NLP bar is hidden by default; toggled from the menu (Assistant IA).
// Showing it eagerly bloats the header on narrow laptops and the // 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) { 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 } woModalCtx.value = { scheduled_date: prefillDate || todayStr, assigned_tech: prefillTech || null }
woModalOpen.value = true 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) { 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, subject: formData.subject,
address: formData.address, address: formData.address,
duration_h: formData.duration_h, duration_h: formData.duration_h,
priority: formData.priority, priority: formData.priority,
assigned_tech: formData.assigned_tech || null, assigned_tech: formData.assigned_tech || null,
scheduled_date: formData.scheduled_date || null, scheduled_date: formData.scheduled_date || null,
latitude: formData._latitude || null, start_time: slot ? slot.start_time : null, // heure du créneau réservé (sinon non planifié à l'heure)
longitude: formData._longitude || null, latitude: formData._latitude || (slot ? slot._latitude : null),
longitude: formData._longitude || (slot ? slot._longitude : null),
note: formData.description || '', note: formData.description || '',
tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t), tags: (formData.tags || []).map(t => typeof t === 'string' ? { tag: t } : t),
depends_on: formData.depends_on || '', depends_on: formData.depends_on || '',
}) })
bookingSlot.value = null
return r
} }
const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({ const { autoDistribute, optimizeRoute: _optimizeRoute } = useAutoDispatch({
@ -1369,6 +1383,7 @@ onUnmounted(() => {
<button class="sb-wo-btn" style="background:#7c3aed" @click="publishModalOpen=true" title="Publier & envoyer l'horaire"> <button class="sb-wo-btn" style="background:#7c3aed" @click="publishModalOpen=true" title="Publier & envoyer l'horaire">
Publier <span v-if="draftCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#ef4444">{{ draftCount }}</span> Publier <span v-if="draftCount" class="sbs-count" style="position:relative;top:-2px;right:auto;background:#ef4444">{{ draftCount }}</span>
</button> </button>
<button class="sb-wo-btn" @click="showSuggestSlots = true" title="Trouver le meilleur créneau (tech + date + heure) pour un nouveau job" style="margin-right:6px">📅 Créneau</button>
<button class="sb-wo-btn" @click="openWoModal()" title="Nouveau work order">+ WO</button> <button class="sb-wo-btn" @click="openWoModal()" title="Nouveau work order">+ WO</button>
<!-- MENU secondaire / admin <!-- MENU secondaire / admin
@ -1899,6 +1914,8 @@ onUnmounted(() => {
:external-tags="store.allTags" :external-tags="store.allTags"
:external-get-color="getTagColor" :external-get-color="getTagColor"
:submit-handler="confirmWo" /> :submit-handler="confirmWo" />
<!-- P1-C : « Trouver un créneau » backend suggest-slots (gap-finding + route) pré-remplit la création de job -->
<SuggestSlotsDialog v-model="showSuggestSlots" @select="onSlotSelected" />
<JobEditModal v-model="editModal" @confirm="confirmEdit" /> <JobEditModal v-model="editModal" @confirm="confirmEdit" />
<PublishScheduleModal v-model="publishModalOpen" <PublishScheduleModal v-model="publishModalOpen"
:jobs="store.jobs" :technicians="filteredResources" :jobs="store.jobs" :technicians="filteredResources"