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:
parent
7f3ad56188
commit
43c67e3a18
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user