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:
parent
dbbcd721e4
commit
e7b94947cf
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user