gigafibre-fsm/apps/ops/src/pages/RendezVousPage.vue
louispaulb f6f4303e2e feat(ui): T5 — DynamicFilter sur RDV (worklist : recherche + chips Type)
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>
2026-07-04 19:36:32 -04:00

267 lines
17 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.

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