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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
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()
|
||||
|
|
|
|||
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 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(() => {
|
|||
<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>
|
||||
</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>
|
||||
|
||||
<!-- ─────────────────── MENU ⋯ — secondaire / admin ─────────────────────
|
||||
|
|
@ -1899,6 +1914,8 @@ onUnmounted(() => {
|
|||
:external-tags="store.allTags"
|
||||
:external-get-color="getTagColor"
|
||||
: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" />
|
||||
<PublishScheduleModal v-model="publishModalOpen"
|
||||
:jobs="store.jobs" :technicians="filteredResources"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user