gigafibre-fsm/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue
louispaulb 3599ec995f 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>
2026-07-03 07:30:08 -04:00

108 lines
5.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>