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:
parent
7b7980a12b
commit
3599ec995f
|
|
@ -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()
|
||||||
|
|
|
||||||
107
apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue
Normal file
107
apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue
Normal 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>
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user