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>
108 lines
5.6 KiB
Vue
108 lines
5.6 KiB
Vue
<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>
|