feat(ui): simpler créneau dialog — skill chips + defaulted duration + progressive disclosure; fix address autosuggest everywhere

Applying the "one-click defaults + reveal-on-input + less clutter" pattern
the user asked for, on the SuggestSlotsDialog:
- Skill chips (4 main, from assignTypes + getTagColor, passed by parent):
  one click picks the skill AND sets the default duration (SLOT_SKILL_DUR:
  install 2h, réparation/tv 1h, téléphonie 0.5h…), adjustable.
- Progressive disclosure: duration/date/search appear only after an
  address is chosen (hint otherwise). Less useless info up front.
- Selected skill flows into the created job as a tag.

Fix (autosuggest broken everywhere): the dialog used a q-menu (needs a
click trigger, so v-if never opened it) → switched to the q-list-below
pattern. More importantly, useAddressSearch pointed at ERPNext
/api/method/search_address (403/perm) → repointed at the reliable hub
/rpc/search_addresses (Postgres rqa_addresses, works in dev+prod via the
/hub proxy). Fixes autosuggest in UnifiedCreateModal + DispatchPage too.

Verified end-to-end: type "54 chateauguay huntingdon" → suggestions
appear → pick → Installation chip sets 2h → search returns 8 slots
(08:15–10:15). Chips + reveal + green ✓ on validated address.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-03 09:58:35 -04:00
parent df23f232b3
commit 374e0da229
4 changed files with 79 additions and 57 deletions

View File

@ -1,8 +1,10 @@
import { ref } from 'vue' import { ref } from 'vue'
import { HUB_URL } from 'src/config/hub'
/** /**
* Reusable address search composable. * Reusable address search composable (autosuggest RQA).
* Searches ERPNext `search_address` whitelisted method with debounce. * Cherche via le hub `/rpc/search_addresses` (Postgres rqa_addresses, trigram) FIABLE et joignable en dev ET prod
* (le proxy /hub ajoute le jeton). Remplace l'ancien `/api/method/search_address` (ERPNext) qui échouait (403/perm).
*/ */
export function useAddressSearch () { export function useAddressSearch () {
const addrResults = ref([]) const addrResults = ref([])
@ -15,9 +17,9 @@ export function useAddressSearch () {
addrLoading.value = true addrLoading.value = true
addrDebounce = setTimeout(async () => { addrDebounce = setTimeout(async () => {
try { try {
const res = await fetch(`/api/method/search_address?q=${encodeURIComponent(q)}`, { credentials: 'include' }) const res = await fetch(`${HUB_URL}/rpc/search_addresses`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ search_term: q, result_limit: 8 }) })
const data = await res.json() const data = await res.json()
addrResults.value = data.results || data.message?.results || [] addrResults.value = Array.isArray(data) ? data : (data.results || data.message?.results || []) // /rpc renvoie un tableau direct
} catch { addrResults.value = [] } } catch { addrResults.value = [] }
addrLoading.value = false addrLoading.value = false
}, 300) }, 300)

View File

@ -217,6 +217,7 @@ export function useUnifiedCreate (mode, opts = {}) {
form.duration_h = ctx.duration_h || 1 form.duration_h = ctx.duration_h || 1
form.address = ctx.address || '' form.address = ctx.address || ''
form.assigned_tech = ctx.assigned_tech || null form.assigned_tech = ctx.assigned_tech || null
form.tags = Array.isArray(ctx.tags) ? ctx.tags.slice() : [] // compétence/type pré-rempli (ex. chip du « Trouver un créneau »)
form.scheduled_date = ctx.scheduled_date || '' form.scheduled_date = ctx.scheduled_date || ''
form.start_time = ctx.start_time || '' form.start_time = ctx.start_time || ''
form._latitude = ctx._latitude != null ? ctx._latitude : (ctx.latitude != null ? ctx.latitude : null) // coords pré-remplies (réservation par créneau) form._latitude = ctx._latitude != null ? ctx._latitude : (ctx.latitude != null ? ctx.latitude : null) // coords pré-remplies (réservation par créneau)

View File

@ -1,48 +1,50 @@
<script setup> <script setup>
/** /**
* SuggestSlotsDialog « Trouver un créneau » : pour un NOUVEAU job à une adresse, * SuggestSlotsDialog « Trouver un créneau » : UI ÉPURÉE à divulgation progressive.
* interroge le hub (POST /dispatch/suggest-slots : gap-finding par tech + tampon de * 1. Chips de compétence (1 clic) fixe la durée PAR DÉFAUT (souvent liée au skill), ajustable.
* route + classement proximité) et propose les meilleurs créneaux. Le choix émet * 2. Adresse (autosuggest RQA liste sous le champ, pas de menu).
* 'select' avec le créneau + l'adresse/coords/durée → l'appelant pré-remplit la * 3. Les options (durée / date) + « Chercher » n'APPARAISSENT qu'une fois l'adresse choisie.
* création de job (tech + date + heure + adresse). Backend déjà en place. * Le choix d'un créneau émet 'select' (créneau + adresse/coords/durée/skill) → l'appelant pré-remplit la création.
* props.skills = [{ skill, dur, color }] fourni par le parent (réutilise ses compétences + couleurs).
*/ */
import { reactive, ref } from 'vue' import { reactive, ref, watch } from 'vue'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { suggestSlots } from 'src/api/dispatch' import { suggestSlots } from 'src/api/dispatch'
import { useAddressSearch } from 'src/composables/useAddressSearch' import { useAddressSearch } from 'src/composables/useAddressSearch'
defineProps({ modelValue: { type: Boolean, default: false } }) const props = defineProps({
modelValue: { type: Boolean, default: false },
skills: { type: Array, default: () => [] }, // [{ skill, dur, color }]
})
const emit = defineEmits(['update:modelValue', 'select']) const emit = defineEmits(['update:modelValue', 'select'])
const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch() const { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
const target = reactive({ address: '', latitude: null, longitude: null, ville: '' }) const target = reactive({ address: '', latitude: null, longitude: null, ville: '' })
const skill = ref('')
const durationH = ref(1) const durationH = ref(1)
const afterDate = ref(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })) const afterDate = ref(new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }))
const loading = ref(false) const loading = ref(false)
const slots = ref([]) const slots = ref([])
const searched = ref(false) const searched = ref(false)
function onPickAddr (addr) { selectAddr(addr, target) } // Reset à l'ouverture (formulaire propre à chaque fois)
watch(() => props.modelValue, (o) => { if (o) { target.address = ''; target.latitude = null; target.longitude = null; skill.value = ''; durationH.value = 1; slots.value = []; searched.value = false; addrResults.value = [] } })
function pickSkill (s) { skill.value = s.skill; durationH.value = s.dur || 1 } // 1 clic skill + durée par défaut
function onAddrInput (v) { target.latitude = null; target.longitude = null; searchAddr(v) } // retape l'adresse invalide les coords
function onPickAddr (a) { selectAddr(a, target); addrResults.value = [] }
async function search () { async function search () {
if (target.latitude == null || target.longitude == null) { if (target.latitude == null) return
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 loading.value = true; searched.value = true
try { try {
slots.value = await suggestSlots({ slots.value = await suggestSlots({ duration_h: durationH.value, latitude: target.latitude, longitude: target.longitude, after_date: afterDate.value, limit: 8 })
duration_h: durationH.value, latitude: target.latitude, longitude: target.longitude, } catch (e) { Notify.create({ type: 'negative', message: 'Recherche impossible : ' + (e.message || e), timeout: 4000 }); slots.value = [] }
after_date: afterDate.value, limit: 8, finally { loading.value = false }
})
} 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) { function pick (s) {
emit('select', { ...s, _address: target.address, _latitude: target.latitude, _longitude: target.longitude, _duration: durationH.value }) emit('select', { ...s, _address: target.address, _latitude: target.latitude, _longitude: target.longitude, _duration: durationH.value, _skill: skill.value })
emit('update:modelValue', false) emit('update:modelValue', false)
} }
@ -52,7 +54,7 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
<template> <template>
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)"> <q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)">
<q-card style="width:520px;max-width:95vw"> <q-card style="width:500px;max-width:95vw">
<q-card-section class="row items-center q-pb-none"> <q-card-section class="row items-center q-pb-none">
<q-icon name="event_available" color="primary" size="24px" class="q-mr-sm" /> <q-icon name="event_available" color="primary" size="24px" class="q-mr-sm" />
<div class="text-h6">Trouver un créneau</div> <div class="text-h6">Trouver un créneau</div>
@ -60,43 +62,49 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
</q-card-section> </q-card-section>
<q-card-section class="q-gutter-sm"> <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> <!-- 1. Type de job (1 clic) fixe la durée par défaut -->
<q-input dense outlined v-model="target.address" label="Adresse du client" clearable <div v-if="skills.length" class="row items-center q-gutter-xs">
@update:model-value="searchAddr" :loading="addrLoading" autofocus> <span v-for="s in skills" :key="s.skill" class="ss-chip" :class="{ on: skill === s.skill }"
<template #prepend><q-icon name="place" /></template> :style="skill === s.skill ? { background: s.color, borderColor: s.color, color: '#fff' } : { borderColor: s.color, color: s.color }"
<q-menu v-if="addrResults.length" no-focus fit> @click="pickSkill(s)">{{ s.skill }}</span>
<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> </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 /> <!-- 2. Adresse autosuggest RQA (liste sous le champ) -->
<q-input dense outlined v-model="target.address" label="Adresse du client" clearable autofocus
:loading="addrLoading" @update:model-value="onAddrInput">
<template #prepend><q-icon name="place" /></template>
<template #append><q-icon v-if="target.latitude != null" name="check_circle" color="positive" /></template>
</q-input>
<q-list v-if="addrResults.length" dense bordered class="ss-addr-list">
<q-item v-for="(a, i) in addrResults" :key="i" clickable @click="onPickAddr(a)">
<q-item-section avatar style="min-width:26px"><q-icon name="place" size="16px" color="grey-6" /></q-item-section>
<q-item-section>{{ a.address_full }}</q-item-section>
</q-item>
</q-list>
<!-- 3. Options révélées SEULEMENT quand l'adresse est choisie -->
<template v-if="target.latitude != null">
<div class="row items-center q-gutter-sm">
<q-input dense outlined type="number" v-model.number="durationH" label="Durée (h)" :min="0.5" :max="8" :step="0.5" style="width:110px">
<template #prepend><q-icon name="schedule" size="18px" /></template>
</q-input>
<q-input dense outlined type="date" v-model="afterDate" label="Dès le" class="col" />
</div>
<q-btn unelevated color="primary" icon="search" label="Chercher des créneaux" class="full-width" :loading="loading" @click="search" no-caps />
</template>
<div v-else class="text-caption text-grey-6 q-pt-xs">Choisis d'abord une adresse ci-dessus.</div>
</q-card-section> </q-card-section>
<q-separator v-if="searched" /> <q-separator v-if="searched" />
<q-card-section v-if="searched" style="max-height:46vh;overflow:auto" class="q-pt-sm"> <q-card-section v-if="searched" style="max-height:44vh;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-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> <div v-else-if="!slots.length" class="text-grey-6 text-center q-pa-md">Aucun créneau élargis la date.</div>
<q-list v-else separator> <q-list v-else separator>
<q-item v-for="(s, i) in slots" :key="i" clickable @click="pick(s)" class="rounded-borders"> <q-item v-for="(s, i) in slots" :key="i" clickable @click="pick(s)" class="rounded-borders">
<q-item-section avatar> <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-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-section>
<q-item-label class="text-weight-medium">{{ dayLabel(s.date) }} · {{ s.start_time }}{{ s.end_time }}</q-item-label> <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>{{ 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>
<q-item-section side><q-icon name="chevron_right" color="primary" /></q-item-section> <q-item-section side><q-icon name="chevron_right" color="primary" /></q-item-section>
</q-item> </q-item>
@ -105,3 +113,8 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
</q-card> </q-card>
</q-dialog> </q-dialog>
</template> </template>
<style scoped>
.ss-chip { display: inline-flex; align-items: center; padding: 3px 11px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12.5px; font-weight: 600; cursor: pointer; user-select: none; background: #fff; text-transform: capitalize; }
.ss-addr-list { border-radius: 6px; max-height: 168px; overflow-y: auto; }
</style>

View File

@ -1732,8 +1732,8 @@
<!-- #17 Création de job NATIVE OPS (work-order) : réutilise le modal partagé. @created rafraîchit le pool. --> <!-- #17 Création de job NATIVE OPS (work-order) : réutilise le modal partagé. @created rafraîchit le pool. -->
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx" <UnifiedCreateModal v-model="createJobOpen" mode="work-order" :context="createJobCtx"
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" /> :technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
<!-- « Trouver un créneau » choisit tech+date+heure puis pré-remplit la création --> <!-- « Trouver un créneau » chips compétence (durée par défaut) + adresse choisit tech+date+heure puis pré-remplit la création -->
<SuggestSlotsDialog v-model="showSuggestSlots" @select="onSlotSelected" /> <SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" />
</q-page> </q-page>
</template> </template>
@ -2030,8 +2030,8 @@ const createJobOpen = ref(false)
const createJobCtx = ref({}) const createJobCtx = ref({})
const showSuggestSlots = ref(false) const showSuggestSlots = ref(false)
function openCreateJob () { createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut function openCreateJob () { createJobCtx.value = { scheduled_date: '' }; createJobOpen.value = true } // pool (non planifié) par défaut
function onSlotSelected (slot) { // un créneau choisi pré-remplit tech+date+heure+adresse+coords function onSlotSelected (slot) { // un créneau choisi pré-remplit tech+date+heure+adresse+coords+compétence
createJobCtx.value = { assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time, address: slot._address || '', duration_h: slot._duration || 1, _latitude: slot._latitude, _longitude: slot._longitude } createJobCtx.value = { assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time, address: slot._address || '', duration_h: slot._duration || 1, _latitude: slot._latitude, _longitude: slot._longitude, tags: slot._skill ? [slot._skill] : [] }
createJobOpen.value = true createJobOpen.value = true
} }
async function onJobCreated () { createJobOpen.value = false; await reloadPool(); await reloadOccupancy(); $q.notify({ type: 'positive', icon: 'add_task', message: 'Job créé — visible dans « À assigner »', timeout: 2600 }) } async function onJobCreated () { createJobOpen.value = false; await reloadPool(); await reloadOccupancy(); $q.notify({ type: 'positive', icon: 'add_task', message: 'Job créé — visible dans « À assigner »', timeout: 2600 }) }
@ -2489,6 +2489,12 @@ function jobCity (j) {
// Chips-filtres par compétence/type (installation, réparation, tv) dans le panneau d'assignation. // Chips-filtres par compétence/type (installation, réparation, tv) dans le panneau d'assignation.
const assignTypeFilter = ref([]) // types sélectionnés (vide = tous) const assignTypeFilter = ref([]) // types sélectionnés (vide = tous)
const assignTypes = computed(() => { const m = {}; for (const j of assignPanel.jobs) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 } return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n) }) const assignTypes = computed(() => { const m = {}; for (const j of assignPanel.jobs) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 } return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n) })
// Chips « Trouver un créneau » : 4 compétences principales (les + fréquentes du pool, repli liste cœur) + durée PAR DÉFAUT par skill.
const SLOT_SKILL_DUR = { installation: 2, 'réparation': 1, reparation: 1, tv: 1, 'télévision': 1, television: 1, 'téléphonie': 0.5, telephone: 0.5, 'téléphone': 0.5, 'sans-fil': 1.5, fibre: 1.5, support: 0.5 }
const slotSkills = computed(() => {
const top = (assignTypes.value || []).map(t => t.k).filter(k => k && k.toLowerCase() !== 'autre').slice(0, 4)
return (top.length ? top : ['installation', 'réparation', 'tv']).map(k => ({ skill: k, dur: SLOT_SKILL_DUR[k.toLowerCase()] || 1, color: getTagColor(k) }))
})
// Filtre par DATE DUE (chips) « n'afficher que les jobs d'une date précise » // Filtre par DATE DUE (chips) « n'afficher que les jobs d'une date précise »
const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates) const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates)
const assignDates = computed(() => { const assignDates = computed(() => {