feat(dispatch): « Proposer au client » directement dans le flux Dispo par motif (in-ticket/conversation)

Step 1/3 des améliorations. AvailabilityByReason (dialogue partagé, monté dans le ticket ET la conversation) n'offrait
que « Trouver un créneau » (assigner soi-même). Ajout du 2e chemin — laisser le CLIENT choisir — là où le diagnostic se fait :
- Bouton « Proposer au client » → api proposeAppointment → hub /roster/book/propose : résout OU **crée** un Dispatch Job
  ouvert (via createDispatchJob) puis envoie le lien /book par texto. Toast avec « Copier le lien » si pas de SMS.
- roster.js : /roster/book/propose crée le job ouvert si aucun (client ou sujet requis) + pose required_skill.
- Props de contexte passées par les 2 panneaux (source_issue/customer/service_location/phone) — édition additive des
  panneaux co-édités = NON commitée ici (déployée via le bundle).
Vérifié : endpoint 404 attendu sans contexte ; « Proposer au client » compilé dans le bundle ; build OK, leak-clean, déployé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 15:25:37 -04:00
parent dbbcd721e4
commit e7b94947cf
3 changed files with 46 additions and 2 deletions

View File

@ -215,6 +215,9 @@ export const legacyWindowLoad = (start, days) => jget('/dispatch/legacy-sync/win
export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {}) export const returnJobToPool = (job) => jpost('/dispatch/legacy-sync/return-to-pool?job=' + encodeURIComponent(job), {})
// Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? } // Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? }
export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body) export const notifyReschedule = (body) => jpost('/roster/job/notify-reschedule', body)
// Proposer un RDV au client : envoie le lien /book (choix d'un créneau OU proposition de 3 dispos). Résout OU crée le
// job ouvert depuis { job | source_issue | customer } (+ subject/skill/duration/service_location/phone). Voir /roster/book/propose.
export const proposeAppointment = (body) => jpost('/roster/book/propose', body)
// URL d'export GPX du parcours GPS d'un tech (jour courant) via Traccar — { tech, from, to } ISO 8601. window.open() = téléchargement. // URL d'export GPX du parcours GPS d'un tech (jour courant) via Traccar — { tech, from, to } ISO 8601. window.open() = téléchargement.
export const gpxUrl = (tech, from, to) => HUB + '/traccar/gpx?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to) export const gpxUrl = (tech, from, to) => HUB + '/traccar/gpx?tech=' + encodeURIComponent(tech) + '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to)
// Tracé GPS (breadcrumb) d'un tech sur UNE journée → { coords:[[lon,lat]…], count }. Plage exacte (1 jour) obligatoire (Traccar lourd sinon). // Tracé GPS (breadcrumb) d'un tech sur UNE journée → { coords:[[lon,lat]…], count }. Plage exacte (1 jour) obligatoire (Traccar lourd sinon).

View File

@ -16,7 +16,7 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { DEPARTMENTS, suggestDepartments } from 'src/config/departments' import { DEPARTMENTS, suggestDepartments } from 'src/config/departments'
import { resolveSkills } from 'src/api/roster' import { resolveSkills, proposeAppointment } from 'src/api/roster'
import OccupancyBands from 'src/components/shared/OccupancyBands.vue' import OccupancyBands from 'src/components/shared/OccupancyBands.vue'
import { skillSym } from 'src/composables/useSkillIcons' // icônes de compétences (SOURCE UNIQUE, partagée avec Planif) import { skillSym } from 'src/composables/useSkillIcons' // icônes de compétences (SOURCE UNIQUE, partagée avec Planif)
@ -29,6 +29,11 @@ const props = defineProps({
lat: { type: Number, default: null }, lat: { type: Number, default: null },
lng: { type: Number, default: null }, lng: { type: Number, default: null },
connectionType: { type: String, default: '' }, // type de connexion du lieu (Fibre/Wireless/LTE) injecte « sans-fil » si sans-fil connectionType: { type: String, default: '' }, // type de connexion du lieu (Fibre/Wireless/LTE) injecte « sans-fil » si sans-fil
// Contexte pour « Proposer au client » (envoi du lien /book). Le Dispatch Job est résolu OU créé côté hub.
sourceIssue: { type: String, default: '' }, // nom de l'Issue/ticket source (retrouve le job ouvert lié)
customer: { type: String, default: '' }, // id client (retrouve son job ouvert, sinon en crée un)
serviceLocation: { type: String, default: '' },
phone: { type: String, default: '' },
}) })
const emit = defineEmits(['update:modelValue', 'book']) const emit = defineEmits(['update:modelValue', 'book'])
@ -101,6 +106,33 @@ function book () {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
// « Proposer au client » : au lieu d'assigner soi-même, on envoie au client le lien /book (il choisit un créneau réservé
// 5 min, ou propose ses 3 dispos). Le hub résout/crée le Dispatch Job ouvert et envoie le SMS.
const proposing = ref(false)
async function proposeToClient () {
proposing.value = true
try {
const r = await proposeAppointment({
source_issue: props.sourceIssue || '', customer: props.customer || '', phone: props.phone || '',
subject: props.customerName ? ('Rendez-vous — ' + props.customerName) : 'Rendez-vous à planifier',
service_location: props.serviceLocation || '', skill: primarySkill.value || '', duration: durH.value,
})
if (r && r.ok) {
Notify.create({
type: 'positive', icon: r.sms ? 'sms' : 'link',
message: r.sms ? 'Lien de rendez-vous envoyé au client par texto.' : ('Lien prêt — ' + (r.note || 'à transmettre au client.')),
caption: r.url || '', timeout: 6000,
actions: r.url ? [{ label: 'Copier le lien', color: 'white', handler: () => { try { navigator.clipboard.writeText(r.url) } catch (e) {} } }] : [],
})
emit('update:modelValue', false)
} else {
Notify.create({ type: 'negative', message: (r && r.error) || 'Envoi impossible — réessayez.', timeout: 4000 })
}
} catch (e) {
Notify.create({ type: 'negative', message: 'Erreur : ' + (e.message || e), timeout: 4000 })
} finally { proposing.value = false }
}
// Réinitialise + pré-remplit à chaque ouverture. // Réinitialise + pré-remplit à chaque ouverture.
watch(() => props.modelValue, (o) => { watch(() => props.modelValue, (o) => {
if (!o) return if (!o) return
@ -183,6 +215,10 @@ function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
<q-card-actions v-if="resolved" align="right" class="q-px-md q-pb-md"> <q-card-actions v-if="resolved" align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps color="grey-7" label="Fermer" @click="emit('update:modelValue', false)" /> <q-btn flat no-caps color="grey-7" label="Fermer" @click="emit('update:modelValue', false)" />
<q-space />
<q-btn outline no-caps color="primary" icon="sms" label="Proposer au client" :loading="proposing" @click="proposeToClient">
<q-tooltip>Envoie au client un lien pour choisir LUI-MÊME son créneau (réservé 5 min) ou proposer ses 3 disponibilités. Idéal après un diagnostic quand on laisse le client choisir l'heure.</q-tooltip>
</q-btn>
<q-btn unelevated no-caps color="primary" icon="event" label="Trouver un créneau" @click="book"> <q-btn unelevated no-caps color="primary" icon="event" label="Trouver un créneau" @click="book">
<q-tooltip>Ouvre la recherche de créneau pré-remplie ({{ primarySkill || 'sans compétence' }} · {{ durH }} h · dès {{ dayShort(afterDate) }}).</q-tooltip> <q-tooltip>Ouvre la recherche de créneau pré-remplie ({{ primarySkill || 'sans compétence' }} · {{ durH }} h · dès {{ dayShort(afterDate) }}).</q-tooltip>
</q-btn> </q-btn>

View File

@ -1728,7 +1728,12 @@ async function handle (req, res, method, path, url) {
let jobName = b.job let jobName = b.job
if (!jobName && (b.source_issue || b.ticket)) { const rows = await erp.list('Dispatch Job', { filters: [['source_issue', '=', b.source_issue || b.ticket], ['status', 'in', ['open', 'assigned']]], fields: ['name'], limit: 1 }); jobName = rows[0] && rows[0].name } if (!jobName && (b.source_issue || b.ticket)) { const rows = await erp.list('Dispatch Job', { filters: [['source_issue', '=', b.source_issue || b.ticket], ['status', 'in', ['open', 'assigned']]], fields: ['name'], limit: 1 }); jobName = rows[0] && rows[0].name }
if (!jobName && b.customer) { const rows = await erp.list('Dispatch Job', { filters: [['customer', '=', b.customer], ['status', 'in', ['open', 'assigned']]], fields: ['name'], order_by: 'creation desc', limit: 1 }); jobName = rows[0] && rows[0].name } if (!jobName && b.customer) { const rows = await erp.list('Dispatch Job', { filters: [['customer', '=', b.customer], ['status', 'in', ['open', 'assigned']]], fields: ['name'], order_by: 'creation desc', limit: 1 }); jobName = rows[0] && rows[0].name }
if (!jobName) return json(res, 404, { ok: false, error: 'aucun job ouvert à proposer — créez d\'abord l\'intervention' }) if (!jobName && (b.customer || b.subject)) { // aucun job ouvert → on en CRÉE un (ouvert, sans tech ni heure) pour porter le lien client
const created = await require('./dispatch').createDispatchJob({ subject: b.subject || 'Rendez-vous à planifier', customer: b.customer || '', service_location: b.service_location || '', duration_h: Number(b.duration) || 1, source_issue: b.source_issue || '', priority: b.priority || 'medium' })
jobName = created && created.job_id
if (jobName && b.skill) { try { await retryWrite(() => erp.update('Dispatch Job', jobName, { required_skill: b.skill })) } catch (e) {} }
}
if (!jobName) return json(res, 404, { ok: false, error: 'impossible de retrouver ou créer l\'intervention (client ou sujet requis)' })
const job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'booking_token', 'customer', 'customer_name', 'scheduled_date'] }) const job = await erp.get('Dispatch Job', jobName, { fields: ['name', 'booking_token', 'customer', 'customer_name', 'scheduled_date'] })
if (!job) return json(res, 404, { ok: false, error: 'job introuvable' }) if (!job) return json(res, 404, { ok: false, error: 'job introuvable' })
let token = job.booking_token let token = job.booking_token