diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index f274219..5e9df9a 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -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), {}) // Aviser le client d'un report : désassigne + SMS lien /book — { job, phone?, message? } 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. 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). diff --git a/apps/ops/src/components/shared/AvailabilityByReason.vue b/apps/ops/src/components/shared/AvailabilityByReason.vue index 8ca7f24..f854610 100644 --- a/apps/ops/src/components/shared/AvailabilityByReason.vue +++ b/apps/ops/src/components/shared/AvailabilityByReason.vue @@ -16,7 +16,7 @@ import { ref, computed, watch } from 'vue' import { Notify } from 'quasar' 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 { 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 }, lng: { type: Number, default: null }, 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']) @@ -101,6 +106,33 @@ function book () { 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. watch(() => props.modelValue, (o) => { if (!o) return @@ -183,6 +215,10 @@ function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge + + + 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. + Ouvre la recherche de créneau pré-remplie ({{ primarySkill || 'sans compétence' }} · {{ durH }} h · dès {{ dayShort(afterDate) }}). diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 6752b4b..f0a6a19 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -1728,7 +1728,12 @@ async function handle (req, res, method, path, url) { 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.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'] }) if (!job) return json(res, 404, { ok: false, error: 'job introuvable' }) let token = job.booking_token