Worklist de rendez-vous : recherche (job/client/lieu) + chips service_type (dynamiques) + presets ('filter.rdv') ;
`filteredJobs` applique le filtre APRÈS la portée `view` (à planifier/à recontacter/tous), qui reste un toggle séparé.
Client-side, réactif. Build vert, leak 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
267 lines
17 KiB
Vue
267 lines
17 KiB
Vue
<template>
|
||
<q-page padding>
|
||
<div class="row items-center q-mb-md q-gutter-sm">
|
||
<div class="text-h6 text-weight-bold">Rendez-vous clients</div>
|
||
<q-space />
|
||
<q-btn-toggle v-model="view" dense unelevated toggle-color="primary" :options="[
|
||
{ label: 'À planifier', value: 'pending' },
|
||
{ label: 'À recontacter', value: 'reschedule' },
|
||
{ label: 'Tous', value: 'all' },
|
||
]" @update:model-value="onViewChange" />
|
||
<q-btn flat dense round icon="refresh" :loading="loadingJobs" @click="loadJobs" />
|
||
</div>
|
||
|
||
<div class="row q-col-gutter-md">
|
||
<!-- Worklist -->
|
||
<div class="col-12 col-md-4">
|
||
<DynamicFilter v-model="filterState" dense search-placeholder="Job, client, lieu…" :chip-groups="chipGroups"
|
||
preset-key="filter.rdv" :filtered-count="filteredJobs.length" class="q-mb-sm" />
|
||
<q-list bordered separator class="rounded-borders">
|
||
<q-item-label header>
|
||
{{ view === 'reschedule' ? 'À recontacter' : 'Jobs' }} ({{ filteredJobs.length }})
|
||
</q-item-label>
|
||
<q-item v-for="j in filteredJobs" :key="j.name" clickable :active="sel && sel.name === j.name" active-class="bg-blue-1" @click="selectJob(j)">
|
||
<q-item-section>
|
||
<q-item-label class="text-weight-medium">{{ jobTitle(j) }}</q-item-label>
|
||
<q-item-label caption lines="1"><MapPin :size="12" class="q-mr-xs" style="vertical-align:middle;color:#546e7a" />{{ jobLoc(j) }} · {{ fmtDur(j.duration_h) }}</q-item-label>
|
||
<q-item-label caption class="text-grey-5" style="font-size:10px">{{ j.name }}</q-item-label>
|
||
</q-item-section>
|
||
<q-item-section side>
|
||
<q-badge v-if="view === 'reschedule' || j.booking_status === 'À reporter'" color="red" label="à reporter" />
|
||
<q-badge v-else :color="j.scheduled_date ? 'green' : 'orange'" :label="j.scheduled_date ? (j.scheduled_date.slice(5) + (j.start_time ? ' ' + j.start_time.slice(0,5) : '')) : 'à planifier'" />
|
||
</q-item-section>
|
||
</q-item>
|
||
<q-item v-if="!filteredJobs.length"><q-item-section class="text-grey-6">Aucun job.</q-item-section></q-item>
|
||
</q-list>
|
||
</div>
|
||
|
||
<!-- Booking panel -->
|
||
<div class="col-12 col-md-8">
|
||
<q-banner v-if="!sel" class="bg-grey-2">Sélectionne un job à gauche pour prendre rendez-vous.</q-banner>
|
||
<q-card v-else flat bordered>
|
||
<q-card-section class="q-pb-none row items-start">
|
||
<div class="col">
|
||
<div class="text-subtitle1 text-weight-bold">{{ jobTitle(sel) }}</div>
|
||
<div class="text-caption text-grey-7"><MapPin :size="13" style="vertical-align:middle;color:#546e7a" /> {{ jobLoc(sel) }} · durée {{ fmtDur(params.duration) }} · tech actuel : {{ sel.assigned_tech || '—' }}</div>
|
||
<div class="text-caption text-grey-5" style="font-size:10px">{{ sel.name }}</div>
|
||
</div>
|
||
<q-btn flat dense no-caps icon="link" label="Lien client" color="primary" @click="genLink(sel)" />
|
||
</q-card-section>
|
||
|
||
<!-- Bandeau À reporter : actions superviseur -->
|
||
<q-card-section v-if="isReschedule" class="q-pt-sm">
|
||
<q-banner dense rounded class="bg-red-1 text-negative">
|
||
⚠️ Client à recontacter. Propose un créneau ci-dessous (réassignation directe), ou laisse le client choisir :
|
||
<q-btn dense unelevated color="negative" icon="sms" label="Aviser par SMS (lien de reprise)" class="q-ml-sm" :loading="smsing" @click="smsReport(sel)" />
|
||
</q-banner>
|
||
</q-card-section>
|
||
|
||
<q-card-section class="row q-col-gutter-sm items-end">
|
||
<q-input dense outlined v-model="params.skill" label="Compétence requise" style="width:160px" :hint="sel.service_type ? ('auto : ' + sel.service_type) : 'tag (vide = tous)'"><template v-if="params.skill" #append><q-icon name="sell" size="16px" color="teal" /></template></q-input>
|
||
<q-input dense outlined v-model="params.zone" label="Zone" style="width:140px" />
|
||
<q-input dense outlined type="number" step="0.5" v-model.number="params.duration" label="Durée (h)" style="width:100px" />
|
||
<q-input dense outlined type="date" v-model="params.start" label="À partir du" style="width:160px" />
|
||
<q-select dense outlined v-model="params.days" :options="[7,14,21]" label="Jours" emit-value map-options style="width:90px" />
|
||
</q-card-section>
|
||
|
||
<q-tabs v-model="mode" dense align="left" class="text-primary">
|
||
<q-tab name="propose" label="Proposer des créneaux" />
|
||
<q-tab name="client" label="3 dispos du client" />
|
||
</q-tabs>
|
||
<q-separator />
|
||
|
||
<!-- Proposer : créneaux groupés Semaine → Jour, hold à la sélection -->
|
||
<q-card-section v-if="mode === 'propose'">
|
||
<q-btn unelevated color="primary" icon="search" label="Trouver des créneaux" :loading="finding" @click="findSlots" />
|
||
<div v-if="slots.length" class="q-mt-md">
|
||
<div class="text-caption text-grey-7 q-mb-xs">{{ slots.length }} créneaux — clique pour réserver (bloqué {{ holdMinutes }} min pour les autres) :</div>
|
||
<div v-for="wk in slotWeeks" :key="wk.mon">
|
||
<div class="wk-head">{{ wk.label }}</div>
|
||
<div v-for="day in wk.days" :key="day.date" class="q-mb-sm">
|
||
<div class="day-head">{{ dayLabel(day.date) }}</div>
|
||
<div class="slot-grid">
|
||
<div v-for="(s, k) in day.slots" :key="k" class="slot" :class="{ on: chosen === s }" @click="chooseSlot(s)">
|
||
<div class="text-weight-medium">{{ s.start }}–{{ s.end }}</div>
|
||
<div class="text-caption text-grey-7">{{ s.tech_name }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<q-btn v-if="chosen" unelevated color="positive" icon="check" :label="`Confirmer : ${dayLabel(chosen.date)} ${chosen.start} · ${chosen.tech_name}`" class="q-mt-md" :loading="confirming" @click="confirm(chosen)" />
|
||
</div>
|
||
<div v-else-if="searched" class="text-grey-6 q-mt-md">Aucun créneau — élargis la période, la zone ou la compétence (le roster doit être publié, et la politique de créneaux peut filtrer).</div>
|
||
</q-card-section>
|
||
|
||
<!-- 3 dispos client -->
|
||
<q-card-section v-else>
|
||
<div class="text-caption text-grey-7 q-mb-sm">Saisis les 3 disponibilités du client, en ordre de préférence. On place dans le 1er tenable.</div>
|
||
<div v-for="(p, i) in prefs" :key="i" class="row items-center q-gutter-sm q-mb-xs">
|
||
<q-badge color="grey-7">{{ i + 1 }}</q-badge>
|
||
<q-input dense outlined type="date" v-model="p.date" style="width:160px" />
|
||
<q-input dense outlined type="time" v-model="p.start" style="width:120px" />
|
||
</div>
|
||
<q-btn unelevated color="primary" icon="search" label="Vérifier" :loading="fitting" class="q-mt-sm" @click="doFit" />
|
||
|
||
<div v-if="fit" class="q-mt-md">
|
||
<q-banner v-if="fit.chosen" dense rounded class="bg-green-1 text-green-9">
|
||
✅ Choix <b>#{{ fit.chosen.rank }}</b> retenu : {{ dayLabel(fit.chosen.date) }} {{ fit.chosen.start }}–{{ fit.chosen.end }} · {{ fit.chosen.tech_name }}
|
||
<q-btn unelevated color="positive" icon="check" label="Confirmer" class="q-ml-md" :loading="confirming" @click="confirm(fit.chosen)" />
|
||
</q-banner>
|
||
<q-banner v-else dense rounded class="bg-orange-1 text-warning">
|
||
⚠️ Aucune des 3 dispos n'est tenable. Créneaux proposés :
|
||
<div class="q-mt-xs">
|
||
<q-chip v-for="(s, k) in fit.proposed" :key="k" dense>{{ frDate(s.date) }} {{ s.start }} ({{ s.available }} dispo)</q-chip>
|
||
<span v-if="!fit.proposed.length" class="text-grey-7">aucun — élargis la période ou la compétence.</span>
|
||
</div>
|
||
<div class="text-caption q-mt-xs">Passe à « Proposer des créneaux » pour en assigner un.</div>
|
||
</q-banner>
|
||
</div>
|
||
</q-card-section>
|
||
</q-card>
|
||
</div>
|
||
</div>
|
||
</q-page>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||
import { fmtDur } from 'src/composables/useHelpers' // durée canonique (source unique : «30m»/«1h»/«1h30»)
|
||
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
|
||
import { MapPin } from 'lucide-vue-next' // pin monochrome outline (style menu Dispatch)
|
||
import { useQuasar } from 'quasar'
|
||
import * as roster from 'src/api/roster'
|
||
|
||
const $q = useQuasar()
|
||
const jobs = ref([])
|
||
const rescheduleJobs = ref([])
|
||
const view = ref('pending')
|
||
const loadingJobs = ref(false)
|
||
const sel = ref(null)
|
||
const mode = ref('propose')
|
||
const params = reactive({ skill: '', zone: '', duration: 1, start: todayISO(), days: 14 })
|
||
// Les 3 dispos sont PRÉ-REMPLIES avec des valeurs réelles (et non des défauts navigateur vides) →
|
||
// « Vérifier » fonctionne d'emblée ; l'agent ajuste selon ce que dit le client.
|
||
function plusDaysISO (iso, n) { const a = iso.split('-').map(Number); const d = new Date(Date.UTC(a[0], a[1] - 1, a[2])); d.setUTCDate(d.getUTCDate() + n); return d.toISOString().slice(0, 10) }
|
||
function defaultPrefs () { const d1 = plusDaysISO(todayISO(), 1); const d2x = plusDaysISO(todayISO(), 2); return [{ date: d1, start: '09:00' }, { date: d1, start: '13:00' }, { date: d2x, start: '09:00' }] }
|
||
const prefs = ref(defaultPrefs())
|
||
const fit = ref(null); const fitting = ref(false)
|
||
const slots = ref([]); const chosen = ref(null); const finding = ref(false); const searched = ref(false)
|
||
const confirming = ref(false); const smsing = ref(false)
|
||
const holdMinutes = 10
|
||
|
||
function todayISO () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
|
||
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||
const FR_DOW_FULL = ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi']
|
||
const MO = ['janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin', 'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.']
|
||
function d2 (iso) { const a = iso.split('-').map(Number); return new Date(Date.UTC(a[0], a[1] - 1, a[2])) }
|
||
function frDate (iso) { if (!iso) return ''; return FR_DOW[d2(iso).getUTCDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
|
||
function dayLabel (iso) { const dt = d2(iso); return FR_DOW_FULL[dt.getUTCDay()] + ' ' + dt.getUTCDate() + ' ' + MO[dt.getUTCMonth()] }
|
||
// Titre LISIBLE d'un job : type de service + client (l'ID DJ-… est secondaire). Lieu = adresse lisible si dispo.
|
||
function jobTitle (j) { if (!j) return ''; const t = (j.service_type || '').trim(); const c = (j.customer_name || '').trim(); if (t) return c ? (t + ' — ' + c) : t; return c || j.location_label || j.name }
|
||
function jobLoc (j) { return (j && (j.location_label || j.service_location)) || '—' }
|
||
// fmtDur → useHelpers.fmtDur (source unique)
|
||
function wkMon (iso) { const dt = d2(iso); const off = (dt.getUTCDay() + 6) % 7; dt.setUTCDate(dt.getUTCDate() - off); return dt.toISOString().slice(0, 10) }
|
||
function wkLabel (m) { const a = d2(m); const b = new Date(a); b.setUTCDate(b.getUTCDate() + 6); return 'Semaine du ' + a.getUTCDate() + ' ' + MO[a.getUTCMonth()] + ' – ' + b.getUTCDate() + ' ' + MO[b.getUTCMonth()] }
|
||
|
||
const isReschedule = computed(() => view.value === 'reschedule' || (sel.value && sel.value.booking_status === 'À reporter'))
|
||
// Filtre dynamique (client) de la worklist : recherche (job/client/lieu) + chips Type. La PORTÉE (à planifier/à recontacter/tous) reste le toggle `view`.
|
||
const filterState = ref({ search: '', chips: {} })
|
||
const serviceTypes = computed(() => [...new Set(jobs.value.map(j => (j.service_type || '').trim()).filter(Boolean))].sort().map(t => ({ value: t, label: t })))
|
||
const chipGroups = computed(() => [{ key: 'type', label: 'Type', icon: 'category', options: serviceTypes.value }])
|
||
const filteredJobs = computed(() => {
|
||
let base = view.value === 'reschedule' ? rescheduleJobs.value : (view.value === 'all' ? jobs.value : jobs.value.filter(j => !j.scheduled_date || j.booking_status !== 'Confirmé'))
|
||
const t = filterState.value.chips.type
|
||
if (t) base = base.filter(j => (j.service_type || '').trim() === t)
|
||
const q = (filterState.value.search || '').toLowerCase().trim()
|
||
if (q) base = base.filter(j => (jobTitle(j) + ' ' + jobLoc(j) + ' ' + (j.customer_name || '')).toLowerCase().includes(q))
|
||
return base
|
||
})
|
||
// Créneaux groupés Semaine → Jour
|
||
const slotWeeks = computed(() => {
|
||
const byWeek = {}
|
||
for (const s of slots.value) {
|
||
const mon = wkMon(s.date)
|
||
const w = byWeek[mon] || (byWeek[mon] = { mon, label: wkLabel(mon), days: {} })
|
||
;(w.days[s.date] || (w.days[s.date] = [])).push(s)
|
||
}
|
||
return Object.values(byWeek).sort((a, b) => a.mon.localeCompare(b.mon)).map(w => ({
|
||
mon: w.mon, label: w.label, days: Object.keys(w.days).sort().map(date => ({ date, slots: w.days[date] })),
|
||
}))
|
||
})
|
||
|
||
async function loadJobs () {
|
||
loadingJobs.value = true
|
||
try {
|
||
jobs.value = (await roster.bookJobs()).jobs || []
|
||
rescheduleJobs.value = ((await roster.jobsToReschedule()).jobs || []).map(j => ({ ...j, booking_status: 'À reporter' }))
|
||
} catch (e) { err(e) } finally { loadingJobs.value = false }
|
||
}
|
||
function onViewChange () { sel.value = null; resetPanel() }
|
||
function resetPanel () { fit.value = null; slots.value = []; chosen.value = null; searched.value = false; prefs.value = defaultPrefs() }
|
||
async function selectJob (j) { await releaseChosen(); sel.value = j; params.duration = Number(j.duration_h) || 1; params.skill = j.required_skill || ''; resetPanel() } // compétence requise dérivée du type de job
|
||
|
||
async function doFit () {
|
||
const valid = prefs.value.filter(p => p.date && p.start)
|
||
if (!valid.length) { $q.notify({ type: 'warning', message: 'Saisis au moins une dispo' }); return }
|
||
fitting.value = true
|
||
try { fit.value = await roster.bookFit({ skill: params.skill, zone: params.zone, duration: params.duration, prefs: valid }) } catch (e) { err(e) } finally { fitting.value = false }
|
||
}
|
||
async function findSlots () {
|
||
finding.value = true; searched.value = true; await releaseChosen(); chosen.value = null
|
||
try { slots.value = (await roster.bookSlots({ skill: params.skill, zone: params.zone, duration: params.duration, start: params.start, days: params.days, limit: 60 })).slots || [] } catch (e) { err(e) } finally { finding.value = false }
|
||
}
|
||
// Réserver temporairement la fenêtre choisie (libère la précédente)
|
||
async function chooseSlot (s) {
|
||
if (chosen.value && chosen.value !== s) { try { await roster.bookHold({ date: chosen.value.date, start: chosen.value.start, release: true }) } catch (e) {} }
|
||
chosen.value = s
|
||
try { await roster.bookHold({ date: s.date, start: s.start, minutes: holdMinutes }) } catch (e) {}
|
||
}
|
||
async function releaseChosen () { if (chosen.value) { try { await roster.bookHold({ date: chosen.value.date, start: chosen.value.start, release: true }) } catch (e) {} } }
|
||
|
||
async function confirm (s) {
|
||
confirming.value = true
|
||
try {
|
||
const prefsUsed = prefs.value.filter(p => p.date && p.start)
|
||
const r = await roster.bookConfirm({ job: sel.value.name, tech: s.tech, date: s.date, start: s.start, duration: params.duration, prefs: prefsUsed })
|
||
if (r.ok === false) { err(new Error(r.error || 'échec')); return }
|
||
$q.notify({ type: 'positive', message: `RDV confirmé : ${dayLabel(s.date)} ${s.start} · ${s.tech_name || s.tech}` })
|
||
chosen.value = null // hold libéré côté serveur à la confirmation
|
||
await loadJobs(); sel.value = (jobs.value.concat(rescheduleJobs.value)).find(j => j.name === sel.value?.name) || null; resetPanel()
|
||
} catch (e) { err(e) } finally { confirming.value = false }
|
||
}
|
||
|
||
async function genLink (j) {
|
||
try {
|
||
const r = await roster.bookLink(j.name)
|
||
if (r.url) { try { await navigator.clipboard.writeText(r.url) } catch (e) {} $q.notify({ type: 'positive', message: 'Lien copié : ' + r.url, timeout: 7000, multiLine: true }) }
|
||
} catch (e) { err(e) }
|
||
}
|
||
function smsReport (j) {
|
||
$q.dialog({
|
||
title: 'Aviser le client par SMS',
|
||
message: 'Le job sera désassigné (remis au pool) et un SMS avec le lien de reprise de RDV sera envoyé au client. Continuer ?',
|
||
cancel: true, persistent: true,
|
||
}).onOk(async () => {
|
||
smsing.value = true
|
||
try {
|
||
const r = await roster.notifyReschedule({ job: j.name })
|
||
$q.notify({ type: r.sms ? 'positive' : 'warning', message: r.sms ? ('SMS envoyé · ' + (r.phone || '')) : (r.note || r.error || 'Statut « À reporter » posé (pas de SMS).'), timeout: 6000 })
|
||
await loadJobs(); view.value = 'reschedule'; sel.value = null; resetPanel()
|
||
} catch (e) { err(e) } finally { smsing.value = false }
|
||
})
|
||
}
|
||
function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) }
|
||
|
||
onMounted(loadJobs)
|
||
onBeforeUnmount(releaseChosen)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.slot-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 6px; }
|
||
.slot { border: 1px solid #e0e0e0; border-radius: 6px; padding: 6px 8px; cursor: pointer; font-size: 12px; text-align: center; }
|
||
.slot:hover { border-color: #1976d2; }
|
||
.slot.on { background: #c8e6c9; border-color: #2e7d32; }
|
||
.wk-head { background: #1565c0; color: #fff; font-weight: 700; font-size: 13px; padding: 6px 12px; border-radius: 6px; margin: 14px 0 6px; }
|
||
.day-head { font-weight: 600; font-size: 12px; color: #444; margin: 6px 0 4px; }
|
||
</style>
|