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:
parent
df23f232b3
commit
374e0da229
|
|
@ -1,8 +1,10 @@
|
|||
import { ref } from 'vue'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
/**
|
||||
* Reusable address search composable.
|
||||
* Searches ERPNext `search_address` whitelisted method with debounce.
|
||||
* Reusable address search composable (autosuggest RQA).
|
||||
* 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 () {
|
||||
const addrResults = ref([])
|
||||
|
|
@ -15,9 +17,9 @@ export function useAddressSearch () {
|
|||
addrLoading.value = true
|
||||
addrDebounce = setTimeout(async () => {
|
||||
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()
|
||||
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 = [] }
|
||||
addrLoading.value = false
|
||||
}, 300)
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ export function useUnifiedCreate (mode, opts = {}) {
|
|||
form.duration_h = ctx.duration_h || 1
|
||||
form.address = ctx.address || ''
|
||||
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.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)
|
||||
|
|
|
|||
|
|
@ -1,48 +1,50 @@
|
|||
<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.
|
||||
* SuggestSlotsDialog — « Trouver un créneau » : UI ÉPURÉE à divulgation progressive.
|
||||
* 1. Chips de compétence (1 clic) → fixe la durée PAR DÉFAUT (souvent liée au skill), ajustable.
|
||||
* 2. Adresse (autosuggest RQA — liste sous le champ, pas de menu).
|
||||
* 3. Les options (durée / date) + « Chercher » n'APPARAISSENT qu'une fois l'adresse choisie.
|
||||
* 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 { suggestSlots } from 'src/api/dispatch'
|
||||
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 { addrResults, addrLoading, searchAddr, selectAddr } = useAddressSearch()
|
||||
const target = reactive({ address: '', latitude: null, longitude: null, ville: '' })
|
||||
const skill = ref('')
|
||||
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) }
|
||||
// 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 () {
|
||||
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
|
||||
}
|
||||
if (target.latitude == null) 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 }
|
||||
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 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('select', { ...s, _address: target.address, _latitude: target.latitude, _longitude: target.longitude, _duration: durationH.value, _skill: skill.value })
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +54,7 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
|||
|
||||
<template>
|
||||
<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-icon name="event_available" color="primary" size="24px" class="q-mr-sm" />
|
||||
<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 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>
|
||||
<!-- 1. Type de job (1 clic) → fixe la durée par défaut -->
|
||||
<div v-if="skills.length" class="row items-center q-gutter-xs">
|
||||
<span v-for="s in skills" :key="s.skill" class="ss-chip" :class="{ on: skill === s.skill }"
|
||||
:style="skill === s.skill ? { background: s.color, borderColor: s.color, color: '#fff' } : { borderColor: s.color, color: s.color }"
|
||||
@click="pickSkill(s)">{{ s.skill }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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)">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 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" :disable="target.latitude == null" @click="search" no-caps />
|
||||
<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-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-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-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 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>
|
||||
|
|
@ -105,3 +113,8 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -1732,8 +1732,8 @@
|
|||
<!-- #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"
|
||||
: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 -->
|
||||
<SuggestSlotsDialog v-model="showSuggestSlots" @select="onSlotSelected" />
|
||||
<!-- « 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" :skills="slotSkills" @select="onSlotSelected" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
|
|
@ -2030,8 +2030,8 @@ const createJobOpen = ref(false)
|
|||
const createJobCtx = ref({})
|
||||
const showSuggestSlots = ref(false)
|
||||
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
|
||||
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 }
|
||||
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, tags: slot._skill ? [slot._skill] : [] }
|
||||
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 }) }
|
||||
|
|
@ -2489,6 +2489,12 @@ function jobCity (j) {
|
|||
// 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 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 »
|
||||
const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates)
|
||||
const assignDates = computed(() => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user