@@ -1497,7 +1520,7 @@
Équipe / renfort
- {{ a.tech_name || techNameById(a.tech_id) || a.tech_id || '—' }}
+ {{ jdAssistantName(a) }}
Aucun assistant. Ajoutez un renfort → un bloc hachuré sera réservé dans son horaire (anti double‑booking).
@@ -1517,10 +1540,69 @@
{{ jobDetail.detail }}
{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}
+
+
+
+
+
+
+
+ Publier l'horaire
+
+
+
+ {{ pubSummary.added }} ajout(s) · {{ pubSummary.removed }} retrait(s) · {{ notifySms ? pubSummary.added + ' SMS aux techs' : 'sans SMS' }}
+
+
+ {{ fmtDueLabel(d.date) }}
+ {{ a.name }} · {{ a.shift }}
+ {{ r.name }} · {{ r.shift }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Réserver du temps
+
+
+
+ {{ resvDlg.tech && resvDlg.tech.name }} — indisponible au dispatch auto pendant ce bloc
+
+
+
+
+
+
+ Les réparations & urgences peuvent quand même utiliser ce tech (recherche de créneau en mode urgence).
+
+
+
+
+
+
+
+
@@ -1700,7 +1782,7 @@
-
Trié par priorité puis heure · 🔴 urgent 🟠 élevée 🔵 moyenne ⚪ basse. Heures posées en premier-trou-libre.
+
Trié par priorité puis heure · 🔴 Urgent 🟠 Moyenne ⚪ Basse. Heures posées en premier-trou-libre.
@@ -1930,7 +2012,7 @@
-
+
@@ -1962,6 +2044,7 @@ import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } fr
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
+import { usePermissions } from 'src/composables/usePermissions'
import * as addressApi from 'src/api/address' // recherche d'adresse RQA (coords) pour le sélecteur d'emplacement
import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes
@@ -2290,10 +2373,12 @@ function startCreneau () { createMode.value = 'work-order'; createChooser.value
const quoteWizardOpen = ref(false)
const projectWizardOpen = ref(false)
const quoteCustomer = ref(null)
+const quoteTier = ref(null) // forfait d'intérêt choisi dans la vitrine QuoteWizard → pré-remplit le panier ProjectWizard
function onQuoteQualified (payload) {
const cust = (payload && payload.customer) || null
// Rattache l'adresse qualifiée (+ zone tarifaire) au client passé à ProjectWizard → portée sur le devis (le prix dépend de l'adresse).
quoteCustomer.value = cust ? { ...cust, qualified_address: (payload && payload.address) || null } : null
+ quoteTier.value = (payload && payload.tier) || null
if (quoteCustomer.value) projectWizardOpen.value = true
}
function onQuoteCreated (job, meta) {
@@ -2384,7 +2469,7 @@ function exportGpx (techId) {
window.open(roster.gpxUrl(techId, from, to), '_blank')
}
// ── Détails d'un job : double-clic sur un bloc → grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. ──
-const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', time: '', durH: 1, detail: '', lid: null, iso: '', dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, assignTech: null, geofence: null, status: '' })
+const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', skills: [], time: '', durH: 1, detail: '', lid: null, iso: '', dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, assignTech: null, geofence: null, status: '' })
// Décode les entités HTML (« d'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
@@ -2396,7 +2481,7 @@ async function openJobDetail (b, t) {
// legacy_detail/legacy_ticket_id) — ouvert depuis une goutte/carrousel. On mappe les DEUX formes pour toujours montrer
// client, compétence, description et le fil du billet.
const lid = b.legacy_id || b.legacy_ticket_id || null
- Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || b.customer_name || ''), address: deEnt(b.address || b.service_location || ''), skill: b.skill || b.required_skill || '', time: (b.start ? (b.start + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : '')) : (b.scheduled_date ? fmtDueLabel(b.scheduled_date) : '')), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || b.legacy_detail || ''), lid, iso: b.scheduled_date || b.iso || (boardView.value === 'routes' ? routesDay.value : '') || '', dept: b.dept || b.legacy_dept || '', techId: (t && t.id) || b.assigned_tech || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : (b.latitude != null ? +b.latitude : null), lon: b.lon != null ? +b.lon : (b.longitude != null ? +b.longitude : null), loading: !!lid, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null, assignTech: null, status: b.status || '' })
+ Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || b.customer_name || ''), address: deEnt(b.address || b.service_location || ''), skill: b.skill || b.required_skill || '', skills: (Array.isArray(b.required_skills) && b.required_skills.length ? b.required_skills.filter(Boolean) : (b.skill || b.required_skill ? [b.skill || b.required_skill] : [])), time: (b.start ? (b.start + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : '')) : (b.scheduled_date ? fmtDueLabel(b.scheduled_date) : '')), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || b.legacy_detail || ''), lid, iso: b.scheduled_date || b.iso || (boardView.value === 'routes' ? routesDay.value : '') || '', dept: b.dept || b.legacy_dept || '', techId: (t && t.id) || b.assigned_tech || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : (b.latitude != null ? +b.latitude : null), lon: b.lon != null ? +b.lon : (b.longitude != null ? +b.longitude : null), loading: !!lid, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null, assignTech: null, status: b.status || '' })
// Géofencing (suivi façon colis) : timeline En route → Arrivé → Reparti, non bloquant.
jobDetail.geofence = null
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
@@ -2415,6 +2500,27 @@ const geoTimeline = computed(() => {
// Options du sélecteur d'assistant (TechSelect, recherche par nom OU compétence) : tous les techs sauf le lead + l'équipe déjà là.
// Label = nom · compétences (searchable) ; CAPABLES d'abord (possèdent la compétence du job) ; value = id (scalaire).
function techNameById (id) { const t = (techs.value || []).find(x => x.id === id); return t ? t.name : '' } // repli nom d'un assistant depuis l'id
+// Nom d'assistant ROBUSTE : une vieille ligne a pu être persistée avec tech_name = la CHAÎNE « undefined »/« null » → on la traite comme vide et on résout par l'id.
+function jdAssistantName (a) { const bad = v => !v || v === 'undefined' || v === 'null'; if (a && !bad(a.tech_name)) return a.tech_name; const n = techNameById(a && a.tech_id); return n || (a && !bad(a.tech_id) ? a.tech_id : '—') }
+// Éditeur de COMPÉTENCES REQUISES du job (SkillSelect multi + création) + attribution à un tech (dispatch auto).
+const jdSkillTech = ref(null)
+const jdSkillBusy = ref(false)
+// LISTE de compétences requises du job → store hub /roster/job-skills (PAS un champ ERPNext). required_skill(principale)=1re. Le tech doit les avoir TOUTES.
+async function jdSetJobSkills (list) {
+ const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))] // SkillSelect émet une CSV ("a,b"), pas un tableau
+ jobDetail.skills = arr; jobDetail.skill = arr[0] || ''
+ if (jobDetail.name) { try { await roster.setJobSkills(jobDetail.name, arr); if (typeof reloadPool === 'function') reloadPool() } catch (e) { err(e) } }
+}
+const jdAllTechOpts = computed(() => (techs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 3).join(', ') : ''), value: t.id })))
+async function jdGiveSkillToTech () { // attribue au tech TOUTES les compétences requises du job qu'il n'a pas encore
+ const req = (jobDetail.skills && jobDetail.skills.length) ? jobDetail.skills : (jobDetail.skill ? [jobDetail.skill] : [])
+ const tid = jdSkillTech.value; if (!req.length || !tid) return
+ const t = (techs.value || []).find(x => x.id === tid); if (!t) return
+ const missing = req.filter(s => !(t.skills || []).includes(s))
+ if (!missing.length) { $q.notify({ type: 'info', message: t.name + ' a déjà ces compétences' }); jdSkillTech.value = null; return }
+ jdSkillBusy.value = true
+ try { const skills = [...(t.skills || []), ...missing]; await roster.setTechSkills(t.id, skills.join(','), t.skill_levels || {}, t.skill_eff || {}); t.skills = skills; jdSkillTech.value = null; $q.notify({ type: 'positive', icon: 'construction', message: missing.join(', ') + ' → ' + t.name + ' (dispatch)' }) } catch (e) { err(e) } finally { jdSkillBusy.value = false }
+}
const jdTeamOptions = computed(() => {
const taken = new Set([jobDetail.techId, ...(jobDetail.team || []).map(a => a.tech_id)])
const req = jobDetail.required_skill || jobDetail.skill || ''
@@ -2425,6 +2531,24 @@ const jdTeamOptions = computed(() => {
})
// Fil du billet, PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
const jdMessages = computed(() => { const m = (jobDetail.thread && jobDetail.thread.messages) || []; return m.slice().reverse() })
+// Répondre au fil depuis la job (note interne par défaut ; au client si coché). Attribution = agent connecté (prénom+nom).
+const { userName: _opsUserName } = usePermissions()
+const jdReply = ref('')
+const jdReplyPublic = ref(false)
+const jdReplySending = ref(false)
+async function sendJdReply () {
+ const text = jdReply.value.trim(); if (!text || jdReplySending.value) return
+ jdReplySending.value = true
+ try {
+ const r = await roster.postJobComment({ job: jobDetail.name, lid: jobDetail.lid, text, isPublic: jdReplyPublic.value, agentName: _opsUserName.value || '' })
+ if (r && r.ok) {
+ const wasPublic = jdReplyPublic.value
+ jdReply.value = ''; jdReplyPublic.value = false
+ $q.notify({ type: 'positive', message: wasPublic ? 'Réponse envoyée au client' : 'Note interne ajoutée', timeout: 2200 })
+ if (jobDetail.lid) { try { jobDetail.thread = await roster.ticketThread(jobDetail.lid) } catch (e) {} } // recharge le fil
+ } else { $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de l\'envoi', timeout: 3000 }) }
+ } catch (e) { $q.notify({ type: 'negative', message: 'Échec de l\'envoi', timeout: 3000 }) } finally { jdReplySending.value = false }
+}
async function jdAddAssistant () {
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
const tech = (techs.value || []).find(t => t.id === id)
@@ -2657,6 +2781,23 @@ const skillDialog = ref(null) // tech dont on édite les compétences
const skillMenuTarget = ref(null) // élément cliqué = ancre du popover (près de la souris, sur la rangée)
const skillMenuShown = ref(false)
function openSkillEditor (t, ev) { skillDialog.value = t; skillMenuTarget.value = (ev && ev.currentTarget) || null; skillMenuShown.value = true; loadTraccarDevices() } // charge les appareils GPS pour la section « Appareil GPS » du volet
+// #5 — Réserver du temps d'un tech (bloc « Réservation », priorité moyenne). Occupe l'occupation → dé-priorise au dispatch auto + soustrait des créneaux.
+const resvDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 2, reason: '', busy: false })
+function openReserve (t) {
+ if (!t) return
+ skillMenuShown.value = false
+ const iso = mobileSelIso.value || kbSelIso.value || start.value || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
+ Object.assign(resvDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, reason: '', busy: false })
+}
+async function doReserve () {
+ if (!resvDlg.tech || !resvDlg.date || resvDlg.busy) return
+ resvDlg.busy = true
+ try {
+ const r = await roster.reserveTech({ tech: resvDlg.tech.id, date: resvDlg.date, start_time: resvDlg.start, duration_h: resvDlg.dur, reason: resvDlg.reason })
+ if (r && r.ok) { $q.notify({ type: 'positive', icon: 'event_busy', message: 'Temps réservé — ' + resvDlg.tech.name + ' · ' + resvDlg.dur + 'h' }); resvDlg.open = false; try { await reloadOccupancy() } catch (e) {} }
+ else $q.notify({ type: 'negative', message: (r && r.error) || 'Réservation impossible' })
+ } catch (e) { $q.notify({ type: 'negative', message: 'Réservation impossible' }) } finally { resvDlg.busy = false }
+}
// « Voir sa tournée » depuis le volet réglages du tech : bascule en vue Tournées + isole ce tech sur la carte.
function viewTechTournee (tech) {
if (!tech || !tech.id) return
@@ -2687,11 +2828,15 @@ async function onScheduleApply ({ schedule, weeks, techIds, mode }) {
// ── RÉCURRENT : le patron devient la SOURCE (weekly_schedule) → matérialisation auto (fériés/vacances sautés, manuels préservés) ──
if (mode === 'recurring') {
const patt = {}; for (const d of schedule) patt[d.dow] = d.on ? { start: d.start, end: d.end } : null
- let okT = 0
- for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++ } catch (e) {} }
- let mat = null; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { err(e) }
+ let okT = 0, failT = 0
+ for (const t of targets) { try { const r = await roster.setWeeklySchedule(t.id, patt); if (r && r.ok) okT++; else { failT++; err(new Error('weekly-schedule ' + t.name + ': ' + ((r && r.error) || 'échec'))) } } catch (e) { failT++; err(e) } }
+ let mat = null, matErr = ''; try { mat = await roster.materializeShifts({ weeks }) } catch (e) { matErr = (e && e.message) || 'erreur'; err(e) }
try { await loadWeek() } catch (e) {}
- $q.notify({ type: okT ? 'positive' : 'warning', icon: 'event_repeat', message: 'Horaire récurrent défini · ' + okT + ' tech' + (mat ? ' → ' + mat.created + ' quart(s) sur ' + weeks + ' sem.' + (mat.skipped_holiday ? ' (' + mat.skipped_holiday + ' férié(s) sauté(s))' : '') : ''), timeout: 4800 })
+ // #4c : on NE SWALLOW PLUS — les échecs (compte tech introuvable, création de quart refusée) remontent à l'écran.
+ const parts = [okT + ' tech']
+ if (failT) parts.push('⚠ ' + failT + ' non enregistré(s)')
+ if (mat) { parts.push(mat.created + ' quart(s)/' + weeks + ' sem.'); if (mat.create_fail) parts.push('⚠ ' + mat.create_fail + ' échec(s) de création'); if (mat.skipped_holiday) parts.push(mat.skipped_holiday + ' férié(s) sauté(s)') } else if (matErr) parts.push('⚠ matérialisation : ' + matErr)
+ $q.notify({ type: (failT || (mat && mat.create_fail) || matErr) ? 'warning' : 'positive', icon: 'event_repeat', message: 'Horaire récurrent · ' + parts.join(' · '), timeout: 5500 })
schedGenTechs.value = []
return
}
@@ -4078,10 +4223,25 @@ function hoursOf (techId) { let h = 0; for (const a of assignments.value) { if (
const serverSet = ref(new Set())
const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|' + a.date + '|' + a.shift)))
const diffKeys = computed(() => { const cur = currentSet.value; const srv = serverSet.value; const d = []; for (const k of cur) if (!srv.has(k)) d.push(k); for (const k of srv) if (!cur.has(k)) d.push(k); return d })
-const dirty = computed(() => diffKeys.value.length > 0)
-const dirtyCount = computed(() => diffKeys.value.length)
-const dirtyCells = computed(() => new Set(diffKeys.value.map(k => k.slice(0, k.lastIndexOf('|')))))
+// #2/#3 — « dirty » = quarts NON PUBLIÉS (statut ≠ Publié). Tout est AUTO-SAUVÉ en brouillon → plus d'alerte « modifications non publiées » à la navigation.
+const unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié'))
+const dirty = computed(() => unpublished.value.length > 0)
+const dirtyCount = computed(() => unpublished.value.length)
+const dirtyCells = computed(() => new Set(unpublished.value.map(a => a.tech + '|' + a.date)))
function isCellDirty (techId, iso) { return dirtyCells.value.has(techId + '|' + iso) }
+// Statut agrégé de la semaine (pour l'affichage #3) : le « plus bas » statut non publié présent.
+const weekStatus = computed(() => { const st = new Set(unpublished.value.map(a => a.status)); if (st.has('Proposé')) return 'Proposé'; if (st.has('Soumis')) return 'Soumis'; if (st.has('Approuvé')) return 'Approuvé'; return 'Publié' })
+// #1 — Sommaire AVANT publication : liste les quarts NON PUBLIÉS (à publier) par jour + nb SMS.
+const pubConfirm = ref(false)
+const pubSummary = computed(() => {
+ const nameOf = id => { const t = (techs.value || []).find(x => x.id === id); return (t && t.name) || id }
+ const byDate = {}
+ for (const a of unpublished.value) {
+ const d = (byDate[a.date] = byDate[a.date] || { date: a.date, add: [], rem: [] })
+ d.add.push({ tech: a.tech, date: a.date, shift: a.shift_name || a.shift, name: nameOf(a.tech), status: a.status })
+ }
+ return { added: unpublished.value.length, removed: 0, days: Object.values(byDate).sort((x, y) => String(x.date).localeCompare(String(y.date))) }
+})
const holSet = computed(() => new Set(holidays.value))
const statHolSet = computed(() => new Set(statHolidays.value.map(h => h.date)))
@@ -4388,11 +4548,12 @@ function setJobReqLevel (job, n) { const lv = Math.max(1, Math.min(5, Number(n)
function techsForJob (job) {
const iso = jobTargetDay(job)
const reqSkill = job && job.required_skill
+ const reqSkillList = (job && Array.isArray(job.required_skills) && job.required_skills.length) ? job.required_skills : (reqSkill ? [reqSkill] : [])
const jlat = +(job && job.lat); const jlon = +(job && job.lon)
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
return (visibleTechs.value || []).map(t => {
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
- const capable = !reqSkill || (t.skills || []).includes(reqSkill)
+ const capable = !reqSkillList.length || reqSkillList.every(s => (t.skills || []).includes(s))
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
const distKm = (hasJC && home) ? haversineKm(home.lat, home.lon, jlat, jlon) : null
return { id: t.id, name: t.name, _t: t, capable, distKm, ...load }
@@ -4641,7 +4802,8 @@ function buildSuggestion () {
const plan = []
const holdByDay = {} // jobs SANS tech de quart → regroupés par jour puis découpés en tournées PLACEHOLDER (pass 2)
for (const j of sorted) {
- const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence explicite, sinon déduite du TYPE de job
+ const reqSkill = j.required_skill || skillByType.value[j.service_type] || '' // compétence PRINCIPALE (déduite du TYPE si absente)
+ const reqSkillList = (Array.isArray(j.required_skills) && j.required_skills.length) ? j.required_skills : (reqSkill ? [reqSkill] : []) // LISTE : le tech doit toutes les avoir
const reqLevel = Number(j.required_level) || 1 // niveau imposé PAR LE JOB (persistant) — plus de défaut global par compétence
const jlat = +(j.lat != null ? j.lat : j.latitude), jlon = +(j.lon != null ? j.lon : j.longitude); const hasLL = isFinite(jlat) && isFinite(jlon) && (jlat || jlon) // pool hub = latitude/longitude (bruts ERP) ; grille = lat/lon
const jcity = jobCity(j) || '' // municipalité — sert de repli de proximité quand le job n'a pas de coordonnées
@@ -4663,7 +4825,7 @@ function buildSuggestion () {
let best = null
for (const t of techs) {
// SKILL = filtre DUR : un tech sans la compétence requise n'est JAMAIS candidat (ex. Josée-Anne ne fait ni réparation ni installation).
- if (reqSkill && !(t.skills || []).includes(reqSkill)) continue
+ if (reqSkillList.length && !reqSkillList.every(s => (t.skills || []).includes(s))) continue
const capable = true
const skillRank = reqSkill ? Math.max(0, (t.skills || []).indexOf(reqSkill)) : 0 // ORDRE de la compétence chez le tech : 0 = principale (spécialiste) ; 1,2… = secondaire (polyvalent)
const lvl = reqSkill ? (skillLevelOf(t, reqSkill) || 0) : 3 // maîtrise 0-5 (3 = neutre sans compétence requise)
@@ -5322,8 +5484,9 @@ function toggleSel (j) {
if (selectedJobs[j.name]) delete selectedJobs[j.name]; else selectedJobs[j.name] = true
}
// ── Actions rapides du pool (mobile, façon Gmail : glisser + icônes directes ★/📝) ──
-// ⚠ Le champ Dispatch Job.priority = Select { low | medium | high } UNIQUEMENT (pas de « urgent » → 417). L'étoile = « high » (le plus prioritaire dispo).
-const POOL_PRIOS = [{ value: 'high', label: 'Élevée', color: '#ef4444' }, { value: 'medium', label: 'Moyenne', color: '#f59e0b' }, { value: 'low', label: 'Basse', color: '#9e9e9e' }]
+// ⚠ Le champ Dispatch Job.priority = Select { low | medium | high } UNIQUEMENT (pas de « urgent » → 417). L'étoile = « high ».
+// POOL_PRIOS = priorités Dispatch Job — SOURCE UNIQUE (config/dispatch-priority).
+import { DISPATCH_PRIORITIES as POOL_PRIOS } from 'src/config/dispatch-priority'
const POOL_STATUSES = [{ value: 'open', label: 'Ouvert', icon: 'inbox', color: 'grey-7' }, { value: 'On Hold', label: 'En attente', icon: 'pause_circle', color: 'orange-7' }, { value: 'Cancelled', label: 'Annulé', icon: 'cancel', color: 'red-6' }]
const jobSheet = reactive({ open: false, job: null })
const noteDialog = reactive({ open: false, job: null, text: '' })
@@ -5336,10 +5499,18 @@ async function patchJob (j, patch, okMsg) {
try { await roster.updateJob(j.name, patch); if (okMsg) $q.notify({ type: 'positive', message: okMsg, timeout: 1600 }) }
catch (e) { err(e); await reloadPool() }
}
-function toggleUrgent (j) { const on = j.priority === 'high'; patchJob(j, { priority: on ? 'medium' : 'high' }, on ? 'Priorité normale' : '⭐ Prioritaire') }
+function toggleUrgent (j) { const on = j.priority === 'high'; patchJob(j, { priority: on ? 'medium' : 'high' }, on ? 'Priorité moyenne' : '⚡ Urgent') }
function setJobPriority (j, p) { patchJob(j, { priority: p }, 'Priorité : ' + ((POOL_PRIOS.find(x => x.value === p) || {}).label || p)) }
function setJobStatus (j, s) { patchJob(j, { status: s }, 'Statut : ' + ((POOL_STATUSES.find(x => x.value === s) || {}).label || s)) }
-function setJobSkill (j, sk) { patchJob(j, { required_skill: sk }, 'Compétence : ' + (sk || '—')) }
+// Compétences requises (LISTE) du job depuis la feuille du pool → store hub /roster/job-skills (PAS un champ ERPNext). SkillSelect émet une CSV. Optimiste + réconcilie sur échec.
+async function setPoolSkills (j, list) {
+ if (!j) return
+ const arr = [...new Set((Array.isArray(list) ? list : String(list || '').split(',')).map(s => String(s).trim()).filter(Boolean))]
+ j.required_skills = arr; j.required_skill = arr[0] || '' // affichage/couleur/icône immédiats
+ if (!j.name) return
+ try { await roster.setJobSkills(j.name, arr); $q.notify({ type: 'positive', message: 'Compétences : ' + (arr.join(', ') || '—'), timeout: 1600 }) }
+ catch (e) { err(e); await reloadPool() }
+}
// Reporter : +N jours (base = date actuelle sinon aujourd'hui).
function snoozeJob (j, days) {
if (!j) return
@@ -5580,11 +5751,12 @@ function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; r
// undo / redo
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
function pushHistory () { history.value.push(snap()); if (history.value.length > 40) history.value.shift(); future.value = [] }
-function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop() }
-function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop() }
+function undo () { if (!history.value.length) return; future.value.push(snap()); assignments.value = history.value.pop(); logChange('↶ Annulé'); scheduleDraftSave() }
+function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop(); logChange('↷ Rétabli'); scheduleDraftSave() }
// garde anti-perte
-function guard (fn) { if (dirty.value && !window.confirm(DIRTY_MSG)) return; fn() }
+// #2 — plus de blocage : tout est auto-sauvé en brouillon. On ATTEND le flush (semaine courante) AVANT de naviguer (évite d'écrire sur la nouvelle semaine).
+async function guard (fn) { await autosaveDraft(); fn() }
function onWeekChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { start.value = lastWeek.start; return } loadWeek() }
function onDaysChange () { if (dirty.value && !window.confirm(DIRTY_MSG)) { days.value = lastWeek.days; return } loadWeek() }
function navWeek (dir) { guard(() => { start.value = addDaysISO(start.value, dir * days.value); loadWeek() }) }
@@ -5668,12 +5840,26 @@ async function doGenerate () {
$q.notify({ type: 'positive', message: 'Horaire généré : ' + solverStats.value.assignments + ' assignations (non publié)' })
} catch (e) { err(e) } finally { generating.value = false }
}
-async function doPublish () {
+// #1 — « Publier » ouvre d'abord le sommaire des changements ; la publication réelle se fait sur confirmation.
+function doPublish () { if (!dirty.value) return; pubConfirm.value = true }
+async function doPublishConfirmed () {
publishing.value = true
try {
- // Réécriture de semaine : efface la période + recrée la grille (anti-doublons).
- const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value)
- $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
+ // Mode 'publish' : promeut les brouillons (Proposé/Soumis/Approuvé) → Publié + SMS. Les éditions étaient déjà auto-sauvées.
+ const r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value, 'publish')
+ const done = (r.created || 0) + (r.promoted || 0)
+ $q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
+ pubConfirm.value = false
+ await loadWeek()
+ } catch (e) { err(e) } finally { publishing.value = false }
+}
+// #3 — étape d'approbation FACULTATIVE : Soumettre (→ Soumis) / Approuver (→ Approuvé), sans SMS. « Publier » reste l'étape finale (SMS).
+async function doWeekStatus (mode) {
+ publishing.value = true
+ try {
+ const r = await roster.publishWeek(start.value, days.value, assignments.value, false, mode)
+ const n = (r.created || 0) + (r.promoted || 0)
+ $q.notify({ type: r.errors ? 'warning' : 'positive', message: (mode === 'submit' ? 'Soumis pour approbation' : 'Approuvé') + ' · ' + n + ' quart(s)' + (r.errors ? ' · ' + r.errors + ' err.' : '') })
await loadWeek()
} catch (e) { err(e) } finally { publishing.value = false }
}
@@ -5851,10 +6037,22 @@ function rect (sti, sdi, eti, edi) {
function onDown (ti, di, ev) { if (ev.button !== 0 || ev.shiftKey || ev.ctrlKey || ev.metaKey) return; drag.on = true; drag.ti = ti; drag.di = di; drag.moved = false; drag.base = [] }
function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.value = [...new Set([...drag.base, ...rect(drag.ti, drag.di, ti, di)])] }
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
-function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }] }
-function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept }
+// #2 — AUTO-SAVE : chaque édition de la grille est persistée en BROUILLON (statut 'Proposé') après un court debounce. Plus rien n'est « non sauvegardé ».
+const autosaving = ref(false)
+const changeLog = ref([]) // journal de session : { at, text }
+let _draftT = null
+function logChange (text) { changeLog.value.unshift({ at: Date.now(), text }); if (changeLog.value.length > 60) changeLog.value.pop() }
+function scheduleDraftSave () { if (_draftT) clearTimeout(_draftT); _draftT = setTimeout(autosaveDraft, 800) }
+async function autosaveDraft () {
+ if (_draftT) { clearTimeout(_draftT); _draftT = null }
+ if (autosaving.value) { scheduleDraftSave(); return } // une sauvegarde en cours → replanifie
+ autosaving.value = true
+ try { await roster.publishWeek(start.value, days.value, assignments.value, false, 'draft') } catch (e) { /* non bloquant : réessaie au prochain edit */ } finally { autosaving.value = false }
+}
+function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }]; logChange('Quart ajouté · ' + techName + ' · ' + iso); scheduleDraftSave() }
+function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept; logChange('Quart défini · ' + techName + ' · ' + iso); scheduleDraftSave() }
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
-function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)) }
+function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)); logChange('Quart retiré · ' + iso); scheduleDraftSave() }
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
function openShiftMenu (t, d, ev, ti, di) {
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
@@ -5974,7 +6172,7 @@ function onKey (e) {
toggleGardeCells(targets); if (selection.value.length) selection.value = []
}
}
-function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } }
+function onUnload () { if (dirty.value && !autosaving.value) autosaveDraft() } // auto-save actif → aucune alerte ; on tente un dernier flush best-effort
// ── Veille Legacy → Ops (SSE topic 'dispatch') : reflète fermeture / réassignation hors-Ops / activité en direct ──
let _legacyRefreshT = null
function scheduleLegacyRefresh () {
@@ -6001,7 +6199,7 @@ const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
-onBeforeRouteLeave(() => { if (dirty.value && !window.confirm(DIRTY_MSG)) return false })
+onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le brouillon (attendu) en partant ; plus jamais d'« abandonner ? »