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>
This commit is contained in:
louispaulb 2026-06-04 14:28:14 -04:00
parent 7f3ad56188
commit 43c67e3a18
3 changed files with 187 additions and 41 deletions

View File

@ -67,3 +67,11 @@ export const bookJobs = () => jget('/roster/book/jobs')
export const bookSlots = (p) => jget('/roster/book/slots?' + new URLSearchParams(p).toString())
export const bookFit = (body) => jpost('/roster/book/fit', body)
export const bookConfirm = (body) => jpost('/roster/book/confirm', body)
// Hold temporaire d'une fenêtre (agent qui sélectionne) — { date, start, minutes } ou { date, start, release:true }
export const bookHold = (body) => jpost('/roster/book/hold', body)
// Lien client (token) pour un job → { token, url }
export const bookLink = (job) => jpost('/roster/book/link', { job })
// File « À recontacter » (jobs À reporter)
export const jobsToReschedule = () => jget('/roster/jobs-to-reschedule')
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)

View File

@ -23,6 +23,33 @@
<div v-if="policySaved" class="text-positive text-caption q-mt-xs"> Politique enregistrée</div>
</q-card>
<!-- #56 Créneaux offerts à la prise de RDV -->
<q-card flat bordered class="q-pa-md q-mb-md">
<div class="text-subtitle2 q-mb-xs">Créneaux offerts à la prise de RDV</div>
<div class="text-caption text-grey-7 q-mb-sm">
Contrôle ce qui est proposé sur la page client et dans la vue agent. Les créneaux restent
dérivés des shifts du roster ces réglages les <b>bornent</b> (délai, heures, jours, plafond).
</div>
<div class="row q-col-gutter-sm">
<div v-for="f in bookingFields" :key="f.key" class="col-6 col-sm-4">
<q-input dense outlined type="number" :min="f.min" :max="f.max"
v-model.number="booking[f.key]" :label="f.label" :suffix="f.unit" :hint="f.hint" />
</div>
</div>
<div class="q-mt-md">
<div class="text-caption text-grey-7 q-mb-xs">Jours offerts</div>
<q-chip v-for="d in weekdays" :key="d.v" clickable
:color="(booking.days_offered || []).includes(d.v) ? 'primary' : 'grey-4'"
:text-color="(booking.days_offered || []).includes(d.v) ? 'white' : 'grey-8'"
@click="toggleDay(d.v)">{{ d.l }}</q-chip>
</div>
<div class="row items-center q-mt-sm">
<q-space />
<q-btn unelevated color="primary" label="Enregistrer" :loading="savingBooking" @click="doSaveBooking" />
</div>
<div v-if="bookingSaved" class="text-positive text-caption q-mt-xs"> Réglages enregistrés</div>
</q-card>
<!-- Chat -->
<q-card flat bordered class="q-pa-md">
<div ref="scrollEl" style="max-height:52vh;overflow:auto" class="q-pb-sm">
@ -66,24 +93,46 @@ const loadingPolicy = ref(false)
const savingPolicy = ref(false)
const policySaved = ref(false)
// #56 réglages des créneaux offerts
const booking = reactive({ lead_hours: 24, day_start: 8, day_end: 18, days_offered: [1, 2, 3, 4, 5], horizon_days: 21, max_per_day: 0, hold_minutes: 10 })
const bookingFields = ref([])
const weekdays = ref([])
const savingBooking = ref(false)
const bookingSaved = ref(false)
onMounted(async () => {
loadingPolicy.value = true
try {
const d = await getPolicy()
Object.assign(policy, d.policy || {})
if (d.policy?.booking) Object.assign(booking, d.policy.booking)
opts.reschedule = d.options?.reschedule || []
opts.escalation = d.options?.escalation || []
bookingFields.value = d.options?.booking_fields || []
weekdays.value = d.options?.weekdays || []
} catch (e) { /* defaults */ }
loadingPolicy.value = false
})
async function doSavePolicy () {
savingPolicy.value = true; policySaved.value = false
try { await savePolicy({ ...policy }); policySaved.value = true; setTimeout(() => { policySaved.value = false }, 2500) }
try { await savePolicy({ reschedule: policy.reschedule, sms_enabled: policy.sms_enabled, sms_quiet_hours: policy.sms_quiet_hours, escalation: policy.escalation }); policySaved.value = true; setTimeout(() => { policySaved.value = false }, 2500) }
catch (e) { /* ignore */ }
savingPolicy.value = false
}
function toggleDay (v) {
if (!Array.isArray(booking.days_offered)) booking.days_offered = []
const i = booking.days_offered.indexOf(v)
if (i >= 0) booking.days_offered.splice(i, 1); else booking.days_offered.push(v)
}
async function doSaveBooking () {
savingBooking.value = true; bookingSaved.value = false
try { await savePolicy({ booking: { ...booking, days_offered: [...(booking.days_offered || [])] } }); bookingSaved.value = true; setTimeout(() => { bookingSaved.value = false }, 2500) }
catch (e) { /* ignore */ }
savingBooking.value = false
}
async function scrollDown () { await nextTick(); if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight }
async function send () {

View File

@ -3,7 +3,11 @@
<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-toggle v-model="onlyPending" label="À planifier seulement" dense />
<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>
@ -11,14 +15,17 @@
<!-- Worklist -->
<div class="col-12 col-md-4">
<q-list bordered separator class="rounded-borders">
<q-item-label header>Jobs ({{ filteredJobs.length }})</q-item-label>
<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.name }}</q-item-label>
<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 :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-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>
@ -29,9 +36,20 @@
<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">
<div class="text-subtitle1 text-weight-bold">{{ sel.name }}</div>
<div class="text-caption text-grey-7">{{ sel.service_location || '—' }} · durée {{ params.duration }}h · tech actuel: {{ sel.assigned_tech || '—' }}</div>
<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">
@ -43,13 +61,35 @@
</q-card-section>
<q-tabs v-model="mode" dense align="left" class="text-primary">
<q-tab name="client" label="3 dispos du client" />
<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-if="mode === '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>
@ -60,7 +100,7 @@
<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 : {{ frDate(fit.chosen.date) }} {{ fit.chosen.start }}{{ fit.chosen.end }} · {{ fit.chosen.tech_name }}
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">
@ -73,23 +113,6 @@
</q-banner>
</div>
</q-card-section>
<!-- Proposer -->
<q-card-section v-else>
<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 sélectionner :</div>
<div class="slot-grid">
<div v-for="(s, k) in slots" :key="k" class="slot" :class="{ on: chosen === s }" @click="chosen = s">
<div class="text-weight-medium">{{ frDate(s.date) }}</div>
<div>{{ s.start }}{{ s.end }}</div>
<div class="text-caption text-grey-7">{{ s.tech_name }}</div>
</div>
</div>
<q-btn v-if="chosen" unelevated color="positive" icon="check" label="Confirmer ce créneau" 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é).</div>
</q-card-section>
</q-card>
</div>
</div>
@ -97,30 +120,63 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
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 onlyPending = ref(true)
const rescheduleJobs = ref([])
const view = ref('pending')
const loadingJobs = ref(false)
const sel = ref(null)
const mode = ref('client')
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 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']
function frDate (iso) { if (!iso) return ''; const [y, m, d] = iso.split('-').map(Number); const dt = new Date(Date.UTC(y, m - 1, d)); return FR_DOW[dt.getUTCDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
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 filteredJobs = computed(() => onlyPending.value ? jobs.value.filter(j => !j.scheduled_date || j.booking_status !== 'Confirmé') : jobs.value)
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 || [] } catch (e) { err(e) } finally { loadingJobs.value = false } }
function selectJob (j) { sel.value = j; params.duration = Number(j.duration_h) || 1; fit.value = null; slots.value = []; chosen.value = null; searched.value = false }
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)
@ -129,27 +185,60 @@ async function doFit () {
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; 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: 40 })).slots || [] } catch (e) { err(e) } finally { finding.value = false }
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é : ${frDate(s.date)} ${s.start} · ${s.tech_name || s.tech}` })
await loadJobs(); const cur = jobs.value.find(j => j.name === sel.value.name); sel.value = cur || null; fit.value = null; slots.value = []; chosen.value = null
$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(130px, 1fr)); gap: 6px; }
.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>