gigafibre-fsm/apps/ops/src/pages/RendezVousPage.vue
louispaulb ee71f69adc Ops RDV+Copilote: vue agent (semaine/jour + hold), file À recontacter, réglages #56
RendezVousPage:
- Vue segmentée À planifier / À recontacter / Tous.
- Créneaux proposés groupés Semaine → Jour (se situer dans le temps, comme /book).
- Hold à la sélection: bookHold(date,start,10min) → bloque les autres; libéré à la confirmation
  ou au changement de job (onBeforeUnmount).
- File À recontacter (jobs À reporter) + actions: Lien client (copie URL self-serve),
  Aviser par SMS (notify-reschedule: désassigne + SMS lien /book).

CopilotePage: carte réglages des créneaux offerts (#56) — lead_hours, plage horaire,
horizon, max/jour, hold, jours offerts (chips) → savePolicy({booking}).

api/roster.js: bookHold, bookLink, jobsToReschedule, notifyReschedule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 14:28:14 -04:00

245 lines
15 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">
<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">{{ j.customer_name || j.name }}</q-item-label>
<q-item-label caption>{{ j.service_location || '—' }} · {{ j.duration_h || 1 }}h</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">{{ sel.customer_name || sel.name }}</div>
<div class="text-caption text-grey-7">{{ sel.service_location || '—' }} · durée {{ params.duration }}h · tech actuel : {{ sel.assigned_tech || '—' }}</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-red-9">
⚠️ 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:150px" />
<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-orange-9">
⚠️ 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 { 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 })
const prefs = ref([{ date: '', start: '' }, { date: '', start: '' }, { date: '', start: '' }])
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()] }
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'))
const filteredJobs = computed(() => {
if (view.value === 'reschedule') return rescheduleJobs.value
if (view.value === 'all') return jobs.value
return jobs.value.filter(j => !j.scheduled_date || j.booking_status !== 'Confirmé')
})
// 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 }
async function selectJob (j) { await releaseChosen(); sel.value = j; params.duration = Number(j.duration_h) || 1; resetPanel() }
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>