Planif: congés publish-required, menu de cellule partagé, vue Mois

- Absences (congé/pause) passent par le modèle local -> Publier (journal,
  undo, dirty badge) au lieu d'écrire directement au serveur. Applique à la
  grille ET au calendrier par tech (TechScheduleDialog).
- Fix capacité "48h" : l'estimation ignorait l'absence et le patron hebdo.
- Menu de cellule (Jour/Soir/Garde/Absent/heures) extrait en composant
  partagé PlanifCellMenu, réutilisé par la grille et le calendrier par tech.
- Raccourci "Jour" = 8-16 (pas 8-17) ; saisie rapide accepte "816".
- Nouvelle vue Mois (MonthOverview) : qui est en quart/absent par jour +
  couverture par compétence (heures requises vs heures en quart). Remplace
  l'ancien module "Demande - effectif requis par créneau".
This commit is contained in:
louispaulb 2026-07-09 11:32:54 -04:00
parent 0853947c43
commit 3489576212
4 changed files with 573 additions and 231 deletions

View File

@ -0,0 +1,252 @@
<template>
<div class="mov-wrap q-pa-sm">
<!-- Barre : navigation de mois + éditeur des besoins (heures requises par compétence) + génération des besoins solveur -->
<div class="row items-center q-gutter-sm q-mb-sm">
<q-btn flat round dense icon="chevron_left" @click="shiftMonth(-1)" />
<div class="text-subtitle1 text-weight-bold" style="min-width:150px;text-align:center;text-transform:capitalize">{{ monthLabel }}</div>
<q-btn flat round dense icon="chevron_right" @click="shiftMonth(1)" />
<q-btn flat dense no-caps size="sm" icon="today" label="Ce mois" @click="goThisMonth" />
<q-spinner v-if="loading" size="18px" color="primary" />
<q-space />
<q-btn dense flat no-caps size="sm" :color="showReq ? 'indigo' : 'grey-7'" icon="tune" :label="showReq ? 'Masquer les besoins' : 'Besoins par compétence'" @click="showReq = !showReq" />
</div>
<!-- Éditeur : heures REQUISES par compétence × type de jour (semaine / fin de sem. / férié) remplace l'ancienne « Demande ». -->
<q-card v-if="showReq" flat bordered class="q-mb-sm">
<q-card-section class="q-py-sm">
<div class="row items-center q-mb-xs">
<div class="text-weight-medium">Besoins heures requises par compétence</div>
<q-space />
<q-btn dense unelevated color="indigo" size="sm" no-caps icon="playlist_add_check" label="Générer les besoins du mois" :loading="generating" @click="generateRequirements">
<q-tooltip class="bg-grey-9" style="max-width:280px">Traduit ces heures requises en <b>besoins de couverture</b> (Shift Requirements) pour le solveur « Générer », sur tout le mois affiché. Remplace les besoins existants du mois.</q-tooltip>
</q-btn>
</div>
<table class="mov-req">
<thead><tr><th style="text-align:left">Compétence</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th></tr></thead>
<tbody>
<tr v-for="sk in skills" :key="sk">
<td style="text-align:left"><span class="mov-dot" :style="{ background: tagColor(sk) }"></span>{{ sk }}</td>
<td><input type="number" min="0" step="1" class="mov-in" :value="reqOf(sk, 'weekday')" @change="setReq(sk, 'weekday', $event.target.value)" /></td>
<td><input type="number" min="0" step="1" class="mov-in" :value="reqOf(sk, 'weekend')" @change="setReq(sk, 'weekend', $event.target.value)" /></td>
<td><input type="number" min="0" step="1" class="mov-in" :value="reqOf(sk, 'holiday')" @change="setReq(sk, 'holiday', $event.target.value)" /></td>
</tr>
<tr v-if="!skills.length"><td colspan="4" class="text-grey-6 q-pa-sm">Aucune compétence taggez des techniciens.</td></tr>
</tbody>
</table>
<div class="text-caption text-grey-6 q-mt-xs">Heures/jour requises par compétence. La couverture = heures des techs <b>en quart</b> (hors garde, hors absents) qui ont la compétence. Case rouge = déficit.</div>
</q-card-section>
</q-card>
<!-- Calendrier du mois : 1 case/jour, résumé en quart / absents + alertes de couverture -->
<div class="mov-cal">
<div v-for="d in DOW_LABELS" :key="'h' + d" class="mov-dowh">{{ d }}</div>
<div v-for="(c, i) in monthCells" :key="i" class="mov-cell" :class="{ 'mov-empty': !c.inMonth, weekend: c.weekend, today: c.isToday, holiday: c.holiday, short: c.inMonth && c.deficits.length }">
<template v-if="c.inMonth">
<div class="mov-top"><span class="mov-daynum">{{ c.day }}</span><span v-if="c.holiday" class="mov-holi" title="Férié"></span><q-space /><q-icon v-if="c.deficits.length" name="warning" color="negative" size="15px" /></div>
<!-- Initiales des techs en quart (max 5) -->
<div class="mov-inits">
<span v-for="t in c.onShift.slice(0, 5)" :key="t.id" class="mov-init" :style="{ background: t.color }">{{ t.initials }}</span>
<span v-if="c.onShift.length > 5" class="mov-more">+{{ c.onShift.length - 5 }}</span>
</div>
<!-- Compteurs + déficits -->
<div class="mov-foot">
<span class="mov-on" :class="{ zero: !c.onShift.length }">{{ c.onShift.length }} en quart</span>
<span v-if="c.absent.length" class="mov-off">· {{ c.absent.length }} abs.</span>
</div>
<div v-if="c.deficits.length" class="mov-defs">
<span v-for="df in c.deficits" :key="df.skill" class="mov-def">{{ df.skill }} {{ df.deficit }}h</span>
</div>
<q-tooltip v-if="c.onShift.length || c.absent.length || c.coverage.length" anchor="center middle" self="center middle" class="bg-grey-9" style="font-size:11.5px;max-width:320px">
<div class="text-weight-bold q-mb-xs">{{ c.iso }}<span v-if="c.holiday"> · férié</span></div>
<div v-if="c.onShift.length"><b>{{ c.onShift.length }} en quart :</b> {{ c.onShift.map(t => t.name + ' (' + t.hours + 'h)').join(', ') }}</div>
<div v-if="c.absent.length" class="q-mt-xs"><b>{{ c.absent.length }} absent(s) :</b> {{ c.absent.map(t => t.name + ' — ' + t.type).join(', ') }}</div>
<div v-if="c.coverage.length" class="q-mt-xs"><b>Couverture :</b><br><span v-for="cov in c.coverage" :key="cov.skill" :class="{ 'text-negative': cov.deficit > 0 }">• {{ cov.skill }} : {{ cov.avail }}/{{ cov.req }}h<span v-if="cov.deficit > 0"> (manque {{ cov.deficit }}h)</span><br></span></div>
</q-tooltip>
</template>
</div>
</div>
<div class="row items-center q-gutter-md q-mt-xs mov-legend">
<span><i class="mov-lg mov-lg-on"></i>en quart</span>
<span><i class="mov-lg mov-lg-short"></i>couverture insuffisante</span>
<span><i class="mov-lg mov-lg-holi"></i>férié</span>
<q-space />
<span class="text-grey-6">{{ monthTotals.onDays }} jour(s) couvert(s) · {{ monthTotals.shortDays }} en déficit</span>
</div>
</div>
</template>
<script setup>
// Vue MOIS (tous techs) : résumé par jour = qui est EN QUART / ABSENT + alertes de COUVERTURE par compétence
// (heures requises vs heures en quart des techs qualifiés). Remplace l'ancien module « Demande » (besoins par template).
// Auto-contenu : charge ses propres données du mois (assignations, absences, fériés) ; besoins par compétence en localStorage.
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
const props = defineProps({
techs: { type: Array, default: () => [] }, // { id, name, skills:[], color_hex? }
templates: { type: Array, default: () => [] }, // Shift Template (nom, hours, on_call) pour l'unité de couverture + génération
anchor: { type: String, default: '' }, // date ISO dans le mois à afficher (sinon aujourd'hui)
refreshKey: { type: Number, default: 0 },
})
const emit = defineEmits(['generated'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
const DOW_LABELS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
const MO = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const LS_SKILL_DEMAND = 'roster-skill-demand-v1'
const month = ref('') // 'YYYY-MM'
const loading = ref(false)
const generating = ref(false)
const showReq = ref(false)
const asgByDate = ref({}) // iso [{ tech, hours }] (quarts réguliers, hors garde)
const absByKey = ref({}) // 'tech|iso' type
const holiSet = ref(new Set())
const skillDemand = reactive(load())
function load () { try { return JSON.parse(localStorage.getItem(LS_SKILL_DEMAND) || '{}') } catch { return {} } }
function saveReq () { try { localStorage.setItem(LS_SKILL_DEMAND, JSON.stringify(skillDemand)) } catch (e) {} }
function reqOf (sk, slot) { return (skillDemand[sk] && skillDemand[sk][slot]) || 0 }
function setReq (sk, slot, v) { const n = Math.max(0, Number(v) || 0); if (!skillDemand[sk]) skillDemand[sk] = { weekday: 0, weekend: 0, holiday: 0 }; skillDemand[sk][slot] = n; saveReq() }
const skills = computed(() => [...new Set((props.techs || []).flatMap(t => t.skills || []))].sort())
const techById = computed(() => { const m = {}; for (const t of (props.techs || [])) m[t.id] = t; return m })
const tplByName = computed(() => Object.fromEntries((props.templates || []).map(t => [t.name, t])))
function tagColor (sk) { let h = 0; for (let i = 0; i < sk.length; i++) h = (h * 31 + sk.charCodeAt(i)) & 0xffffff; return 'hsl(' + (h % 360) + ',55%,55%)' }
function initials (name) { const p = String(name || '').trim().split(/\s+/); return ((p[0] || '')[0] || '').toUpperCase() + ((p[1] || '')[0] || '').toUpperCase() }
function techColor (t) { return (t && t.color_hex) || tagColor(t ? (t.name || t.id) : '?') }
function pad (n) { return String(n).padStart(2, '0') }
const monthLabel = computed(() => { if (!month.value) return ''; const [y, m] = month.value.split('-').map(Number); return MO[m - 1] + ' ' + y })
const monthDays = computed(() => { if (!month.value) return 0; const [y, m] = month.value.split('-').map(Number); return new Date(Date.UTC(y, m, 0)).getUTCDate() })
const monthStart = computed(() => month.value ? month.value + '-01' : '')
// dayType d'un jour clé de besoin (semaine / fin de sem. / férié)
function dayType (iso, dow) { if (holiSet.value.has(iso)) return 'holiday'; return (dow === 0 || dow === 6) ? 'weekend' : 'weekday' }
const monthCells = computed(() => {
if (!month.value) return []
const [y, m] = month.value.split('-').map(Number)
const first = new Date(Date.UTC(y, m - 1, 1))
const lead = (first.getUTCDay() + 6) % 7 // lundi = 0
const cells = []
for (let i = 0; i < lead; i++) cells.push({ inMonth: false })
const today = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
for (let day = 1; day <= monthDays.value; day++) {
const iso = y + '-' + pad(m) + '-' + pad(day)
const dow = new Date(iso + 'T12:00:00').getUTCDay()
const type = dayType(iso, dow)
// Techs en quart ce jour (hors garde), non absents.
const onShift = []
for (const a of (asgByDate.value[iso] || [])) {
if (absByKey.value[a.tech + '|' + iso]) continue
const t = techById.value[a.tech]; if (!t) continue
onShift.push({ id: t.id, name: t.name, initials: initials(t.name), color: techColor(t), hours: a.hours, skills: t.skills || [] })
}
// Absents ce jour.
const absent = []
for (const t of (props.techs || [])) { const type2 = absByKey.value[t.id + '|' + iso]; if (type2) absent.push({ id: t.id, name: t.name, type: type2 }) }
// Couverture par compétence : heures dispo (techs en quart ayant la compétence) vs requis.
const coverage = []; const deficits = []
for (const sk of skills.value) {
const req = reqOf(sk, type); if (!req) continue
let avail = 0; for (const t of onShift) if ((t.skills || []).includes(sk)) avail += Number(t.hours) || 0
avail = Math.round(avail * 10) / 10
const deficit = Math.max(0, Math.round((req - avail) * 10) / 10)
coverage.push({ skill: sk, avail, req, deficit })
if (deficit > 0) deficits.push({ skill: sk, deficit })
}
cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, holiday: holiSet.value.has(iso), isToday: iso === today, onShift, absent, coverage, deficits })
}
while (cells.length % 7) cells.push({ inMonth: false })
return cells
})
const monthTotals = computed(() => { let onDays = 0, shortDays = 0; for (const c of monthCells.value) { if (!c.inMonth) continue; if (c.onShift && c.onShift.length) onDays++; if (c.deficits && c.deficits.length) shortDays++ } return { onDays, shortDays } })
function initMonth () { const a = props.anchor || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }); month.value = a.slice(0, 7) }
function shiftMonth (n) { const [y, m] = month.value.split('-').map(Number); const d = new Date(Date.UTC(y, m - 1 + n, 1)); month.value = d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1); loadMonth() }
function goThisMonth () { month.value = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }).slice(0, 7); loadMonth() }
async function loadMonth () {
if (!month.value) return
loading.value = true
try {
const start = monthStart.value; const days = monthDays.value
const [asgRes, absRes, holiRes] = await Promise.all([
roster.listAssignments(start, days).catch(() => null),
roster.getAbsences(start, days).catch(() => null),
roster.holidays(start, month.value + '-' + pad(days)).catch(() => null),
])
const abd = {}
for (const a of ((asgRes && asgRes.assignments) || [])) {
const tp = tplByName.value[a.shift]; if (tp && tp.on_call) continue // garde exclue de la couverture
;(abd[a.date] || (abd[a.date] = [])).push({ tech: a.tech, hours: Number(a.hours) || (tp ? tp.hours : 0) || 0 })
}
asgByDate.value = abd
absByKey.value = (absRes && absRes.absences) || {}
holiSet.value = new Set(((holiRes && (holiRes.holidays || [])) || []).map(h => h.date || h))
} catch (e) { err(e) } finally { loading.value = false }
}
// Génère les Shift Requirements du MOIS à partir des heures requises par compétence (remplace les besoins existants du mois).
// Traduction : effectif = heures requises ÷ heures du modèle de jour, compétence requise = la compétence.
async function generateRequirements () {
const dayTpl = (props.templates || []).find(t => !t.on_call && Number(t.hours) >= 7 && Number(t.hours) <= 9) || (props.templates || []).find(t => !t.on_call)
if (!dayTpl) { $q.notify({ type: 'warning', message: 'Aucun modèle de shift de jour — créez-en un (Types de shift).' }); return }
const tplH = Number(dayTpl.hours) || 8
generating.value = true
try {
await roster.clearRequirements(monthStart.value, monthDays.value)
const reqs = []
for (const c of monthCells.value) {
if (!c.inMonth) continue
for (const sk of skills.value) {
const req = reqOf(sk, dayType(c.iso, c.dow)); if (!req) continue
reqs.push({ requirement_date: c.iso, shift_template: dayTpl.name, zone: '', required_count: Math.max(1, Math.ceil(req / tplH)), required_skills: sk })
}
}
if (reqs.length) await roster.bulkRequirements(reqs)
$q.notify({ type: 'positive', message: reqs.length + ' besoin(s) généré(s) pour ' + monthLabel.value + ' — le solveur « Générer » les utilise' })
emit('generated')
} catch (e) { err(e) } finally { generating.value = false }
}
watch(() => props.anchor, () => { const wasMonth = month.value; initMonth(); if (month.value !== wasMonth) loadMonth() })
watch(() => props.refreshKey, () => loadMonth())
onMounted(() => { initMonth(); loadMonth() })
</script>
<style scoped>
.mov-wrap { max-width: 1100px; }
.mov-req { border-collapse: collapse; }
.mov-req th { font-size: 11px; color: #64748b; font-weight: 600; padding: 2px 10px; }
.mov-req td { padding: 2px 10px; font-size: 12.5px; }
.mov-in { width: 64px; padding: 3px 6px; border: 1px solid #cbd5e1; border-radius: 6px; font-size: 12px; text-align: center; }
.mov-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: middle; }
.mov-cal { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.mov-dowh { text-align: center; font-size: 10.5px; color: #64748b; font-weight: 700; padding-bottom: 2px; }
.mov-cell { position: relative; min-height: 92px; border: 1px solid #e2e8f0; border-radius: 8px; padding: 5px 6px; background: #fff; display: flex; flex-direction: column; gap: 3px; }
.mov-empty { border: none; background: transparent; }
.mov-cell.weekend { background: #f8fafc; }
.mov-cell.today { border-color: #2563eb; box-shadow: inset 0 0 0 1px #2563eb; }
.mov-cell.holiday { background: #fffbeb; }
.mov-cell.short { border-color: #ef4444; }
.mov-top { display: flex; align-items: center; gap: 4px; }
.mov-daynum { font-size: 12px; color: #334155; font-weight: 700; }
.mov-holi { color: #b45309; font-size: 11px; }
.mov-inits { display: flex; flex-wrap: wrap; gap: 2px; }
.mov-init { width: 19px; height: 19px; border-radius: 50%; color: #fff; font-size: 9.5px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
.mov-more { font-size: 10px; color: #64748b; align-self: center; }
.mov-foot { font-size: 10.5px; color: #475569; margin-top: auto; }
.mov-on.zero { color: #94a3b8; }
.mov-off { color: #64748b; }
.mov-defs { display: flex; flex-wrap: wrap; gap: 2px; }
.mov-def { font-size: 9.5px; font-weight: 700; color: #b91c1c; background: #fee2e2; border-radius: 4px; padding: 0 4px; }
.mov-legend { font-size: 10.5px; color: #475569; }
.mov-lg { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
.mov-lg-on { background: #2563eb; }
.mov-lg-short { background: #ef4444; }
.mov-lg-holi { background: #fbbf24; }
</style>

View File

@ -0,0 +1,79 @@
<template>
<!-- Contenu du menu de cellule PARTAGÉ (grille semaine/jour ET calendrier par tech).
Le PARENT l'enveloppe dans son propre <q-menu> (ancrage propre à chaque contexte) et câble les intentions émises
à ses propres fonctions (applyWindow / toggleGarde / absence / ). Aucun état de page ici : 100 % réutilisable. -->
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none">
<q-item-label header class="q-py-xs">{{ title }}</q-item-label>
<!-- 4 actions : Jour · Soir · Garde · Absent -->
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense unelevated size="sm" color="primary" label="Jour 816" class="col" @click="$emit('window', { min: 8, max: 16 })" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 1620" class="col" @click="$emit('window', { min: 16, max: 20 })" />
</div>
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense :unelevated="isGarde" :outline="!isGarde" size="sm" color="brown" icon="shield" :label="isGarde ? 'Garde ✓' : 'Garde'" class="col" @click="$emit('toggle-garde')"><q-tooltip>Mettre / retirer de garde (G) en parallèle d'un shift</q-tooltip></q-btn>
<q-btn dense :unelevated="isAbsent" :outline="!isAbsent" size="sm" color="negative" icon="event_busy" :label="isAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="$emit('toggle-absent')"><q-tooltip>Congé / absence à publier</q-tooltip></q-btn>
</div>
<!-- Saisie rapide d'heures : 8-17 · 830-16 · 85 (=817) -->
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
<q-input dense outlined v-model="quickEntry" placeholder="Heures : 8-16 · 816 · 830-16" @keyup.enter="applyQuick">
<template #append><q-btn flat dense round size="sm" icon="keyboard_return" color="primary" @click="applyQuick"><q-tooltip>Appliquer</q-tooltip></q-btn></template>
</q-input>
</div>
<!-- Plage personnalisée (slider replié) -->
<q-expansion-item dense dense-toggle icon="tune" label="Personnaliser la plage" header-class="text-caption text-grey-7">
<div class="q-px-md q-pb-sm" @click.stop @mousedown.stop>
<q-range v-model="range" :min="0" :max="24" :step="0.5" snap color="primary" class="q-mt-sm" />
<div class="row items-center no-wrap q-gutter-sm">
<span class="text-caption text-weight-bold">{{ fmtH(range.min) }}h{{ fmtH(range.max) }}h</span>
<q-space />
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyRange" />
</div>
</div>
</q-expansion-item>
<q-separator />
<!-- Shifts en place + actions compactes -->
<q-item v-for="a in shifts" :key="'c' + (a.shift || a.name)" dense>
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="$emit('remove-shift', a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
</q-item>
<div class="row items-center q-px-sm q-py-xs q-gutter-sm">
<q-btn v-if="showCopy" flat dense size="sm" icon="content_copy" color="grey-8" @click="$emit('copy')"><q-tooltip>Copier la case</q-tooltip></q-btn>
<q-btn v-if="showCopy" flat dense size="sm" icon="content_paste" color="grey-8" :disable="!clipboardCount" @click="$emit('paste')"><q-tooltip>Coller{{ clipboardCount ? ' (' + clipboardCount + ')' : '' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="shifts.length" flat dense size="sm" icon="layers_clear" color="grey-8" label="Vider" @click="$emit('clear')" />
</div>
</q-list>
</template>
<script setup>
// Menu de cellule réutilisable voir en-tête du template. Émet des INTENTIONS ; le parent exécute (single source of logic).
import { ref, watch } from 'vue'
const props = defineProps({
title: { type: String, default: '' },
shifts: { type: Array, default: () => [] }, // [{ shift, shift_name, hours, name? }]
isGarde: { type: Boolean, default: false },
isAbsent: { type: Boolean, default: false },
clipboardCount: { type: Number, default: 0 },
initialRange: { type: Object, default: () => ({ min: 8, max: 16 }) },
cellKey: { type: String, default: '' }, // change à chaque (ré)ouverture réinitialise saisie/slider
showCopy: { type: Boolean, default: true },
})
const emit = defineEmits(['window', 'toggle-garde', 'toggle-absent', 'remove-shift', 'clear', 'copy', 'paste'])
const quickEntry = ref('')
const range = ref({ min: props.initialRange.min, max: props.initialRange.max })
// Réinitialise à chaque ouverture sur une nouvelle cellule (le composant reste monté, seul le q-menu parent s'affiche/masque).
watch(() => props.cellKey, () => { quickEntry.value = ''; range.value = { min: props.initialRange.min, max: props.initialRange.max } })
function fmtH (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
// Parse « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=817, dernier chiffre en pm si début).
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
function parseQuickShift (str) {
const s = (str || '').trim().toLowerCase(); if (!s) return null
if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
// Compact SANS séparateur : « 816 »=816 · « 1016 »=1016 · « 0816 »=816 (2 derniers chiffres = fin, le reste = début).
if (/^\d{3,4}$/.test(s)) { const end = Number(s.slice(-2)); const start = Number(s.slice(0, -2)); if (end <= 24 && start >= 0 && start < end) return { min: start, max: end } }
if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 »=817
return null
}
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) return; quickEntry.value = ''; emit('window', r) }
function applyRange () { if (range.value.max > range.value.min) emit('window', { min: range.value.min, max: range.value.max }) }
</script>

View File

@ -53,14 +53,15 @@
<span v-for="b in BRUSHES" :key="b.type" class="ts-brush" :class="{ on: brush === b.type }"
:style="brush === b.type ? { background: b.color, borderColor: b.color, color: '#fff' } : { borderColor: b.color, color: b.color }"
@click="brush = b.type">{{ b.label }}</span>
<q-space /><span class="text-caption text-grey-5">glisse sur les jours pour appliquer</span>
<q-space /><span class="text-caption text-grey-5">glisse pour marquer · re-clique pour annuler · <b>à publier</b></span>
</div>
<!-- Grille -->
<div class="ts-cal" @mouseleave="endDrag" @mouseup="endDrag">
<!-- Grille clic simple = menu de cellule PARTAGÉ (Jour/Soir/Garde/Absent/heures, comme la semaine/jour) ;
glisser = pinceau rapide (congés multi-jours pour les vacances). -->
<div class="ts-cal" @mouseleave="cancelDrag" @mouseup="endDrag">
<div v-for="d in DOW_LABELS" :key="'h' + d" class="ts-dowh">{{ d }}</div>
<div v-for="(c, i) in monthCells" :key="i"
class="ts-cell" :class="cellClass(c)"
@mousedown.prevent="c.inMonth && startDrag(c)"
@mousedown.prevent="c.inMonth && startDrag(c, $event)"
@mouseenter="c.inMonth && overDrag(c)">
<template v-if="c.inMonth">
<span class="ts-daynum">{{ c.day }}</span>
@ -74,6 +75,16 @@
</template>
</div>
</div>
<!-- Menu de cellule PARTAGÉ (même composant que la grille) ouvert au clic simple sur un jour. -->
<div ref="cellAnchor" style="position:fixed;width:1px;height:1px;pointer-events:none;z-index:0" :style="{ left: cellMenu.x + 'px', top: cellMenu.y + 'px' }"></div>
<q-menu v-model="cellMenu.show" :target="cellAnchor" anchor="bottom right" self="top left" max-height="85vh">
<PlanifCellMenu
:cell-key="cellMenu.iso"
:title="(tech && tech.name) + ' — ' + fmtDayLabel(cellMenu.iso)"
:shifts="menuShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent" :show-copy="false"
@window="onMenuWindow" @toggle-garde="onMenuGarde" @toggle-absent="onMenuAbsent"
@remove-shift="onMenuRemoveShift" @clear="onMenuClear" />
</q-menu>
<div class="row items-center q-gutter-md q-mt-xs ts-legend">
<span v-for="b in BRUSHES.filter(x => x.type !== 'clear')" :key="'l' + b.type"><i class="ts-dot" :style="{ background: b.color }"></i>{{ b.label }}</span>
<span><i class="ts-dot ts-dot-today"></i>aujourd'hui</span>
@ -100,12 +111,16 @@ import { ref, reactive, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import { techOccupancy } from 'src/api/dispatch' // occupation/quart par jour du tech grise les jours sans quart, barre d'occupation les jours avec quart
import PlanifCellMenu from 'src/components/planif/PlanifCellMenu.vue' // menu de cellule PARTAGÉ (identique à la grille semaine/jour)
const props = defineProps({
modelValue: { type: Boolean, default: false },
tech: { type: Object, default: null }, // { id, name, status, ... }
pendingAbs: { type: Object, default: () => ({}) }, // congés EN ATTENTE (delta local du parent, publish-required) superposés à l'état serveur
gardeMap: { type: Object, default: () => ({}) }, // garde EFFECTIVE du parent (gardeEffective) : clé tech|iso shift
refreshKey: { type: Number, default: 0 }, // par le parent après une écriture de quart recharge le mois
})
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed'])
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
@ -145,10 +160,24 @@ const month = ref('') // 'YYYY-MM'
const calLoading = ref(false)
const absMap = ref({}) // 'YYYY-MM-DD' type d'absence
const shiftMap = ref({}) // 'YYYY-MM-DD' { shift:bool, occupancy:0..1 } (quart réel du tech ce jour) gris = pas de quart
const asgByDate = ref({}) // 'YYYY-MM-DD' [{ name, shift, shift_name, hours }] (quarts réguliers du tech ce jour) menu « shifts en place »
const holiSet = ref(new Set())
const drag = reactive({ on: false, sel: new Set() })
const drag = reactive({ on: false, sel: new Set(), moved: false })
const selCount = computed(() => drag.sel.size)
// Menu de cellule (clic simple) ancré au curseur (comme la grille).
const cellAnchor = ref(null)
const cellMenu = reactive({ show: false, iso: '', x: 0, y: 0 })
const menuShifts = computed(() => asgByDate.value[cellMenu.iso] || [])
const menuIsGarde = computed(() => !!(props.tech && props.gardeMap && props.gardeMap[props.tech.id + '|' + cellMenu.iso]))
const menuIsAbsent = computed(() => !!effAbsType(cellMenu.iso))
function fmtDayLabel (iso) { if (!iso) return ''; return iso.slice(8) + '/' + iso.slice(5, 7) }
// Type d'absence EFFECTIF pour un jour = serveur (absMap) écrasé par le delta EN ATTENTE du parent. null = pas absent.
function effAbsType (iso) {
const p = props.pendingAbs && props.tech && props.pendingAbs[props.tech.id + '|' + iso]
if (p) return p.op === 'set' ? (p.type || 'Congé') : null
return absMap.value[iso] || null
}
const MO = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const monthLabel = computed(() => { const [y, m] = month.value.split('-').map(Number); return MO[m - 1] + ' ' + y })
function todayISO () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
@ -167,7 +196,7 @@ const monthCells = computed(() => {
const iso = y + '-' + pad(m) + '-' + pad(day)
const dow = new Date(iso + 'T12:00:00').getUTCDay()
const sh = shiftMap.value[iso]
cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: absMap.value[iso] || null, holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null })
cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: effAbsType(iso), holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null })
}
while (cells.length % 7) cells.push({ inMonth: false })
return cells
@ -195,10 +224,11 @@ async function loadCal () {
const [y, m] = month.value.split('-').map(Number)
const start = y + '-' + pad(m) + '-01'
const days = new Date(Date.UTC(y, m, 0)).getUTCDate()
const [absRes, holiRes, occRes] = await Promise.all([
const [absRes, holiRes, occRes, asgRes] = await Promise.all([
roster.getAbsences(start, days),
roster.holidays(start, y + '-' + pad(m) + '-' + pad(days)),
techOccupancy({ after_date: start, days, skill: '' }).catch(() => null), // quart + occupation par jour (tous techs) on garde le nôtre
roster.listAssignments(start, days).catch(() => null), // quarts réguliers du mois menu « shifts en place » / retrait
])
const out = {}
const abs = (absRes && absRes.absences) || {}
@ -213,29 +243,41 @@ async function loadCal () {
const mine = occRes && (occRes.techs || []).find(x => x.tech_id === props.tech.id || x.tech_name === props.tech.name)
if (mine) for (const d of (mine.days || [])) sh[d.date] = { shift: !d.off, occupancy: d.occupancy }
shiftMap.value = sh
// Quarts réguliers du mois pour CE tech menu « shifts en place » + retrait par docname.
const abd = {}
for (const a of ((asgRes && asgRes.assignments) || [])) {
if (a.tech !== props.tech.id && a.tech !== props.tech.name) continue
;(abd[a.date] || (abd[a.date] = [])).push({ name: a.name, shift: a.shift, shift_name: a.shift_name || a.shift, hours: a.hours })
}
asgByDate.value = abd
} catch (e) { err(e) } finally { calLoading.value = false }
}
// Glisser-sélectionner
function startDrag (c) { drag.on = true; drag.sel = new Set([c.iso]) }
function overDrag (c) { if (drag.on) drag.sel.add(c.iso) }
async function endDrag () {
// Interaction : CLIC SIMPLE (sans glisser) = menu de cellule partagé (Jour/Soir/Garde/Absent/heures) ; GLISSER = pinceau
// rapide de congés (multi-jours, vacances). Le congé reste STAGÉ (publish-required) : on émet le delta au parent, 0 écriture ici.
function startDrag (c, ev) { drag.on = true; drag.moved = false; drag.sel = new Set([c.iso]); if (ev) { cellMenu.x = ev.clientX; cellMenu.y = ev.clientY } }
function overDrag (c) { if (!drag.on) return; if (!drag.sel.has(c.iso)) drag.moved = true; drag.sel.add(c.iso) }
function cancelDrag () { drag.on = false; drag.sel = new Set(); drag.moved = false }
function endDrag () {
if (!drag.on) return
drag.on = false
const days = [...drag.sel]
drag.sel = new Set()
if (!days.length) return
const remove = brush.value === 'clear'
const type = remove ? '' : brush.value
calLoading.value = true
let ok = 0
try {
for (const iso of days) {
try { await roster.setAbsence(props.tech.id, iso, type, remove); ok++; if (remove) delete absMap.value[iso]; else absMap.value[iso] = type } catch (e) { err(e) }
}
if (ok) { $q.notify({ type: 'positive', message: (remove ? 'Effacé' : 'Marqué « ' + type + ' »') + ' — ' + ok + ' jour(s)', timeout: 1800 }); emit('changed', { id: props.tech.id }) }
} finally { calLoading.value = false; absMap.value = { ...absMap.value } }
const days = [...drag.sel]; drag.sel = new Set()
if (!days.length || !props.tech) return
if (!drag.moved) { openCellMenu(days[0]); return } // clic simple menu (pas d'application du pinceau)
const changes = days.map(iso => {
if (brush.value === 'clear') return { iso, type: '' }
const cur = effAbsType(iso)
return { iso, type: cur === brush.value ? '' : brush.value } // même type toggle off
})
emit('stage-abs', { techId: props.tech.id, changes })
}
// Menu de cellule (clic simple) émet des intentions au parent (single source of logic).
function openCellMenu (iso) { cellMenu.iso = iso; cellMenu.show = true }
function onMenuWindow (r) { if (props.tech && r) emit('set-shift', { techId: props.tech.id, iso: cellMenu.iso, min: r.min, max: r.max }); cellMenu.show = false }
function onMenuGarde () { if (props.tech) emit('toggle-garde', { techId: props.tech.id, iso: cellMenu.iso }); cellMenu.show = false }
function onMenuAbsent () { if (props.tech) emit('stage-abs', { techId: props.tech.id, changes: [{ iso: cellMenu.iso, type: menuIsAbsent.value ? '' : 'Congé' }] }); cellMenu.show = false }
function onMenuRemoveShift (a) { if (a && a.name) emit('remove-shift', { name: a.name }); cellMenu.show = false }
function onMenuClear () { if (props.tech) emit('clear-shifts', { techId: props.tech.id, iso: cellMenu.iso, names: (asgByDate.value[cellMenu.iso] || []).map(a => a.name) }); cellMenu.show = false }
// Archivage (réversible)
const archiveBusy = ref(false)
@ -262,9 +304,11 @@ watch(() => props.modelValue, (o) => {
pauseNote.value = ''
const t = todayISO()
month.value = t.slice(0, 7)
absMap.value = {}; shiftMap.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set()
absMap.value = {}; shiftMap.value = {}; asgByDate.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set(); cellMenu.show = false
loadCal()
})
// Le parent a persisté un quart (createShift/deleteAssignment) recharger le mois pour refléter les shifts.
watch(() => props.refreshKey, () => { if (props.modelValue) loadCal() })
</script>
<style scoped>

View File

@ -32,7 +32,7 @@
<!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) DESKTOP/tablette uniquement (gt-sm) -->
<div class="row items-center q-mb-sm q-gutter-xs gt-sm">
<!-- Sélecteur de VUE à GAUCHE, le plus évident (le titre « Planification » est déjà au-dessus, inutile de le répéter) -->
<q-btn-toggle v-model="boardView" dense unelevated no-caps class="view-switch" :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }, { value: 'routes', icon: 'route', label: 'Tournées' }]" toggle-color="primary" color="grey-2" text-color="grey-8"><q-tooltip>Semaine = grille (vue d'ensemble) · Jour = disposition d'une journée · Tournées = trajets réels de la journée, 1 couleur/tech (OSRM)</q-tooltip></q-btn-toggle>
<q-btn-toggle v-model="boardView" dense unelevated no-caps class="view-switch" :options="[{ value: 'grid', icon: 'calendar_view_week', label: 'Semaine' }, { value: 'kanban', icon: 'today', label: 'Jour' }, { value: 'month', icon: 'calendar_month', label: 'Mois' }, { value: 'routes', icon: 'route', label: 'Tournées' }]" toggle-color="primary" color="grey-2" text-color="grey-8"><q-tooltip>Semaine = grille (vue d'ensemble) · Jour = disposition d'une journée · Mois = qui est en quart/absent + couverture par compétence · Tournées = trajets réels (OSRM)</q-tooltip></q-btn-toggle>
<q-chip v-if="dirty" dense size="sm" color="orange" text-color="white" icon="circle">{{ dirtyCount }} non publié(s)</q-chip>
<q-chip v-if="offShiftWeekCount" dense size="sm" color="warning" text-color="white" icon="warning">{{ offShiftWeekCount }} hors quart<q-tooltip class="bg-grey-9">{{ offShiftWeekCount }} job(s) assigné(s) cette période un jour la ressource n'a AUCUN quart publié. Repère le dans la grille publier un quart ou réassigner.</q-tooltip></q-chip>
<q-space />
@ -118,16 +118,16 @@
<q-item-section>Jobs Legacy sur les timelines<q-item-label caption>durées estimées des jobs osTicket datés (fenêtre affichée)</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
</q-item>
<q-item clickable v-close-popup @click="showDemand = !showDemand">
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
<q-item-section>Demande de personnel<q-item-label caption>panneau besoins vs capacité</q-item-label></q-item-section>
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
<q-item clickable v-close-popup @click="boardView = 'month'">
<q-item-section avatar><q-icon name="calendar_month" color="indigo" /></q-item-section>
<q-item-section>Besoins &amp; couverture (vue Mois)<q-item-label caption>heures requises par compétence · alertes de couverture</q-item-label></q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn v-if="defaultTemplate" dense flat color="warning" icon="star" :label="defaultTemplate.name" @click="applyDefault"><q-tooltip>Appliquer le modèle par défaut (consciente des absences)</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" />
<q-btn unelevated color="primary" icon="auto_awesome" label="Suggérer" @click="openSuggest"><q-tooltip class="bg-grey-9">Répartition automatique des jobs du <b>jour sélectionné</b> (distance · compétence · priorité · taux d'occupation) — revue avant d'appliquer</q-tooltip></q-btn>
<HelpHint class="q-ml-xs" title="Suggérer — répartition automatique">Propose une assignation optimisée des jobs <b>du jour sélectionné</b> aux techniciens, en combinant compétence requise, disponibilité, distance (routes réelles) et taux d'occupation. Les cas simples vont d'abord aux techs les <b>moins polyvalents</b> (réserve les experts). Rien n'est appliqué avant votre validation.</HelpHint>
<!-- PUBLIER unifié : action principale + sous-options (SMS · publier au legacy) dans le même bouton -->
<q-btn-dropdown split :outline="!dirty" :unelevated="dirty" color="positive" icon="cloud_upload" :label="dirty ? ('Publier (' + dirtyCount + ')') : 'Publier'" :loading="publishing" :disable="!dirty" no-caps @click="doPublish">
<q-list dense style="min-width:250px">
@ -187,36 +187,7 @@
</template>
</div>
<!-- Demande -->
<q-card v-if="showDemand" flat bordered class="q-mb-md">
<q-card-section class="q-pb-none">
<div class="row items-center">
<div class="text-subtitle2 text-weight-bold">Demande effectif requis par créneau</div><q-space />
<q-btn dense flat icon="schedule" label="Types de shift" @click="showShiftEditor = true" />
<q-btn dense flat icon="add" label="Ajouter" @click="addDemand" />
<q-btn dense unelevated color="indigo" icon="playlist_add_check" label="Appliquer à la semaine" :loading="applying" class="q-ml-sm" @click="applyDemand" />
</div>
<div class="text-caption text-grey-7 q-mt-xs">Coche les jours <b>fériés</b> (F) dans l'en-tête · fin de semaine = sam/dim (auto). Si <b>Durée/job</b> &gt; 0, les nombres = <b>nb de jobs</b> effectif = jobs × durée ÷ heures du shift (compétences requises = colonne Compétences).</div>
</q-card-section>
<q-card-section>
<table class="demand-tbl">
<thead><tr><th>Modèle</th><th>Zone</th><th>Compétences</th><th>Durée/job (h)</th><th>Semaine</th><th>Fin de sem.</th><th>Férié</th><th></th></tr></thead>
<tbody>
<tr v-for="(d, i) in demand" :key="i">
<td><q-select dense options-dense outlined v-model="d.shift" :options="tplOptions" emit-value map-options style="min-width:150px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined v-model="d.zone" style="width:120px" @update:model-value="saveDemand" /></td>
<td><TagEditor :model-value="Array.isArray(d.skills) ? d.skills : []" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" compact placeholder="+ compétence" style="min-width:150px" @update:model-value="items => onDemandSkills(d, items)" @create="onCreateRosterTag" /></td>
<td><q-input dense outlined type="number" step="0.5" v-model.number="d.job_h" placeholder="0" style="width:80px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.weekday" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.weekend" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-input dense outlined type="number" v-model.number="d.holiday" style="width:70px" @update:model-value="saveDemand" /></td>
<td><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="removeDemand(i)" /></td>
</tr>
<tr v-if="!demand.length"><td colspan="8" class="text-grey-6 q-pa-sm">Aucune ligne clique « Ajouter ».</td></tr>
</tbody>
</table>
</q-card-section>
</q-card>
<!-- « Demande effectif requis par créneau » RETIRÉ (remplacé par la vue Mois : besoins par compétence + couverture). -->
<q-banner v-if="solverStats" dense rounded class="q-mb-md" :class="solverStats.shortfall ? 'bg-orange-1 text-warning' : 'bg-green-1 text-green-9'">
<q-icon :name="solverStats.shortfall ? 'warning' : 'check_circle'" class="q-mr-xs" />
@ -227,11 +198,11 @@
du tableau quand la sélection démarre pendant un glisser (le curseur reste sur la rangée visée). -->
<div v-if="selection.length" class="sel-actions" @mousedown.stop>
<span class="text-weight-medium q-mr-xs">{{ selection.length }} cellule(s) :</span>
<q-btn dense unelevated size="sm" color="primary" label="Jour" @click="bulkWindow(8, 17)" />
<q-btn dense unelevated size="sm" color="primary" label="Jour" @click="bulkWindow(8, 16)" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir" @click="bulkWindow(16, 20)" />
<q-btn dense unelevated size="sm" color="brown" icon="shield" label="Garde" @click="bulkGarde" />
<q-btn dense unelevated size="sm" color="negative" icon="event_busy" label="Absent" @click="bulkAbsent" />
<q-input dense outlined v-model="quickEntry" placeholder="8-17" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-17 · 830-16 · 85</q-tooltip></q-input>
<q-input dense outlined v-model="quickEntry" placeholder="8-16" style="width:84px" @keyup.enter="bulkQuick" @mousedown.stop><q-tooltip>Saisie rapide : 8-16 · 816 · 830-16</q-tooltip></q-input>
<q-separator vertical class="q-mx-xs" />
<q-btn dense flat size="sm" icon="content_copy" label="Copier" @click="copyCell" />
<q-btn dense flat size="sm" icon="content_paste" :label="cellClipboard.length ? ('Coller (' + cellClipboard.length + ')') : 'Coller'" :disable="!cellClipboard.length" @click="pasteCells" />
@ -521,6 +492,9 @@
<!-- VUE BOARD (Kanban horizontal, façon Dispatch/Gaiia) : pool « À assigner » VERTICAL (recherche + tri) à gauche ;
techs en LANES horizontales à ÉCHELLE D'HEURES (réutilise cellBands + pos + axisTicks). Glisser = assigner (hub). -->
<!-- P3 Onglet « Tournées » : trajets RÉELS (jobs assignés) de la journée sélectionnée, 1 couleur/tech (composant RouteMap partagé, OSRM) -->
<!-- Vue MOIS : résumé mensuel (en quart / absents) + couverture par compétence (remplace « Demande ») -->
<MonthOverview v-if="boardView === 'month'" :techs="techs" :templates="templates" :anchor="start" @generated="() => guard(loadWeek)" />
<div v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
<span class="text-caption text-grey-7">Journée :</span>
@ -668,7 +642,7 @@
<q-dialog v-model="showGarde">
<q-card style="min-width:680px;max-width:760px">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold">🛡 Rotation de garde (par département)</div><q-space />
<div class="text-subtitle1 text-weight-bold">🛡 Rotation de garde (par département) <HelpHint title="Rotation de garde (sur appel)">La garde, c'est le technicien <b>sur appel</b> hors des heures normales (soir, fin de semaine). Il n'est pas planifié à l'horaire mais reste disponible en cas d'urgence. Cette rotation définit, par département, quel tech est de garde et selon quelle alternance.</HelpHint></div><q-space />
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-card-section class="q-gutter-y-md">
@ -767,7 +741,7 @@
<div class="row items-center text-caption text-grey-6 q-pb-xs">
<div class="col">Compétence</div>
<div style="width:90px" class="text-center">Score</div>
<div style="width:88px" class="text-center">Cadence</div>
<div style="width:88px" class="text-center">Cadence <HelpHint title="Cadence (vitesse)" text="Vitesse du technicien pour cette compétence. 100 % = cadence normale ; plus haut = plus rapide (fait plus de jobs, ex. 200 % = deux fois plus) ; sous 100 % = plus lent. Vide = hérite de la cadence globale du tech. Le dispatch auto en tient compte pour estimer les durées." /></div>
</div>
<div v-for="(sk, si) in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
<div class="col row items-center no-wrap">
@ -1424,7 +1398,6 @@
</AssignmentField>
</div>
<q-space />
<q-btn v-if="jobDetail.name" flat round dense icon="open_in_new" @click="openExternal(erpLink('Dispatch Job', jobDetail.name))"><q-tooltip>Ouvrir dans ERPNext</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section>
<q-separator />
@ -1698,47 +1671,13 @@
<div ref="menuAnchorEl" :style="{ position: 'fixed', width: '1px', height: '1px', left: menu.x + 'px', top: menu.y + 'px', pointerEvents: 'none', zIndex: 0 }"></div>
<q-menu v-model="menu.show" :target="menu.target" anchor="bottom right" self="top left" max-height="85vh">
<q-list dense style="width:262px;user-select:none;-webkit-user-select:none">
<q-item-label header class="q-py-xs">{{ menu.tech && menu.tech.name }} {{ menu.day && menu.day.dnum }}</q-item-label>
<!-- 4 actions : Jour · Soir · Garde · Absent -->
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense unelevated size="sm" color="primary" label="Jour 817" class="col" @click="quickShift(8, 17)" />
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 1620" class="col" @click="quickShift(16, 20)" />
</div>
<div class="row q-gutter-xs q-px-sm q-pb-xs">
<q-btn dense :unelevated="menuIsGarde" :outline="!menuIsGarde" size="sm" color="brown" icon="shield" :label="menuIsGarde ? 'Garde ✓' : 'Garde'" class="col" @click="toggleGardeMenu"><q-tooltip>Mettre / retirer de garde (G) en parallèle d'un shift</q-tooltip></q-btn>
<q-btn dense :unelevated="menuIsAbsent" :outline="!menuIsAbsent" size="sm" color="negative" icon="event_busy" :label="menuIsAbsent ? 'Absent ✓' : 'Absent'" class="col" @click="openAbsDialog"><q-tooltip>Absence : ce jour (défaut), la semaine, ou une plage de dates</q-tooltip></q-btn>
</div>
<!-- Saisie rapide d'heures : 8-17 · 830-16 · 85 (=817) -->
<div class="q-px-sm q-pb-xs" @click.stop @mousedown.stop>
<q-input dense outlined v-model="quickEntry" placeholder="Heures : 8-17 · 830-16 · 85" @keyup.enter="applyQuick()">
<template #append><q-btn flat dense round size="sm" icon="keyboard_return" color="primary" @click="applyQuick()"><q-tooltip>Appliquer</q-tooltip></q-btn></template>
</q-input>
</div>
<!-- Plage personnalisée (slider replié) -->
<q-expansion-item dense dense-toggle icon="tune" label="Personnaliser la plage" header-class="text-caption text-grey-7">
<div class="q-px-md q-pb-sm" @click.stop @mousedown.stop>
<q-range v-model="menuRange" :min="0" :max="24" :step="0.5" snap color="primary" class="q-mt-sm" />
<div class="row items-center no-wrap q-gutter-sm">
<span class="text-caption text-weight-bold">{{ fmtH(menuRange.min) }}h{{ fmtH(menuRange.max) }}h</span>
<q-space />
<q-btn dense unelevated size="sm" color="primary" label="Appliquer" @click="applyMenuRange" />
</div>
</div>
</q-expansion-item>
<q-separator />
<!-- Shifts en place + actions compactes -->
<q-item v-for="a in menuCellShifts" :key="'c' + a.shift" dense>
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
<q-item-section side><q-btn flat dense round size="sm" icon="close" color="grey-7" @click="removeShiftFromMenu(a)"><q-tooltip>Retirer</q-tooltip></q-btn></q-item-section>
</q-item>
<div class="row items-center q-px-sm q-py-xs q-gutter-sm">
<q-btn flat dense size="sm" icon="content_copy" color="grey-8" @click="copyFromMenu"><q-tooltip>Copier la case</q-tooltip></q-btn>
<q-btn flat dense size="sm" icon="content_paste" color="grey-8" :disable="!cellClipboard.length" @click="pasteFromMenu"><q-tooltip>Coller{{ cellClipboard.length ? ' (' + cellClipboard.length + ')' : '' }}</q-tooltip></q-btn>
<q-space />
<q-btn v-if="menuCellShifts.length" flat dense size="sm" icon="layers_clear" color="grey-8" label="Vider" @click="clearOne" />
</div>
</q-list>
<PlanifCellMenu
:cell-key="(menu.tech && menu.tech.id) + '|' + (menu.day && menu.day.iso)"
:title="(menu.tech && menu.tech.name) + ' — ' + (menu.day && menu.day.dnum)"
:shifts="menuCellShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent"
:clipboard-count="cellClipboard.length" :initial-range="menuRange"
@window="e => applyWindow(e.min, e.max)" @toggle-garde="toggleGardeMenu" @toggle-absent="openAbsDialog"
@remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu" />
</q-menu>
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
@ -1925,7 +1864,9 @@
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" @edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" />
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" :pending-abs="pendingAbs" :garde-map="gardeEffective" :refresh-key="techSchedRefresh"
@edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" @stage-abs="onStageAbs"
@set-shift="onTechSchedSetShift" @remove-shift="onTechSchedRemoveShift" @clear-shifts="onTechSchedClearShifts" @toggle-garde="onTechSchedToggleGarde" />
</q-page>
</template>
@ -1961,7 +1902,7 @@ 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
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
import { relTime, initials, erpLink } from 'src/composables/useFormatters' // temps relatif + initiales + lien ERP (source unique, ex-locales dé-dupliquées)
import { relTime, initials } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées)
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) réutilisé sur les jobs (échéance = création + résolution)
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
@ -1971,6 +1912,8 @@ import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & dis
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait décomposition #4)
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait décomposition #4)
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import HelpHint from 'src/components/shared/HelpHint.vue'
import { skillIcon, skillSym, markerIcon } from 'src/composables/useSkillIcons' // SOURCE UNIQUE des icônes de compétences (réutilisée fiche client)
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) SOURCE UNIQUE : jobs ET techs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
@ -1983,6 +1926,8 @@ import { useCreateSignal } from 'src/composables/useCreateSignal' // FAB global
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) réutilisé
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch
import TechScheduleDialog from 'src/components/planif/TechScheduleDialog.vue' // écran unique horaire/pause/congés par tech (icône « event » de la rangée)
import PlanifCellMenu from 'src/components/planif/PlanifCellMenu.vue' // menu de cellule PARTAGÉ (grille + calendrier par tech)
import MonthOverview from 'src/components/planif/MonthOverview.vue' // vue MOIS : qui est en quart / absent + couverture par compétence (remplace « Demande »)
const $q = useQuasar()
const router = useRouter()
@ -2024,7 +1969,7 @@ const visStatByDay = computed(() => {
const vis = new Set(visibleTechs.value.map(t => t.id)); const tpl = tplByName.value; const agg = {}
for (const a of assignments.value) {
if (!vis.has(a.tech)) continue
if (absByTechDay.value[a.tech + '|' + a.date]) continue // congé / pause ce jour quart matérialisé mais tech absent : hors capacité
if (isAbsent(a.tech, a.date)) continue // congé / pause ce jour (serveur OU en attente) quart matérialisé mais tech absent : hors capacité
const t = tpl[a.shift]; if (t && t.on_call) continue // garde = mise en dispo, pas travaillée
const o = agg[a.date] || (agg[a.date] = { hours: 0, staff: new Set() })
o.hours += Number(a.hours) || 0; o.staff.add(a.tech)
@ -2078,17 +2023,16 @@ const unassignedByDay = computed(() => {
})
const dailyStats = ref([])
const solverStats = ref(null)
const loading = ref(false); const generating = ref(false); const publishing = ref(false); const applying = ref(false)
const loading = ref(false); const generating = ref(false); const publishing = ref(false)
const days = ref(14) // 2 semaines par défaut (sélecteur retiré pour éviter les changements d'étendue accidentels)
const start = ref(thisMonday()) // défaut = semaine COURANTE (cohérent avec « Auj. ») pas la semaine suivante
const lastWeek = reactive({ start: start.value, days: days.value })
const showDemand = ref(false)
const drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] })
const justDragged = ref(false)
const selection = ref([])
const activeCell = ref(null) // dernière case cliquée {id, name, iso} pour copier/coller au clavier sans multi-sélection
const anchor = ref(null)
const demand = ref([]); const holidays = ref([]); const weekTemplates = ref([])
const holidays = ref([]); const weekTemplates = ref([])
const statHolidays = ref([]) // fériés QC DÉTERMINISTES (calendrier hub /roster/holidays) fusionnés dans isHoliday, tous navigateurs
const gardeRules = ref([]); const showGarde = ref(false)
const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' 'on' | 'off' (touche « G »)
@ -2150,7 +2094,7 @@ const showLeave = ref(false) // ouverture de <LeaveDialog> (état leaveRows/leav
// numToTime : heure décimale HH:MM (partagé aussi utilisé par le glisser-créer de quart). newTpl/newTplRange déplacés dans ShiftTypesDialog.
function numToTime (h) { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return String(hh).padStart(2, '0') + ':' + String(mm).padStart(2, '0') }
const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
// Date LOCALE du jour (PAS toISOString, qui bascule au lendemain le soir en UTC cause des « jours décalés »).
function todayISO () { const d = new Date(); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0') }
@ -2174,7 +2118,7 @@ watch(dayMode, (on) => { if (on) showDayJobs.value = true }) // en mode Jour, af
// VUE KANBAN (mode de vue, sur le socle hub) : colonne « À assigner » + 1 colonne/tech, cartes glissables entre lanes.
// Réutilise tout : occupation (avec équipes via job.assist), pool unassignedJobs, onCellDrop (= roster.assignJob hub).
const { prefs: planifPrefs, save: savePlanif } = useUserPrefs('planif', { view: 'grid' }) // préférence d'affichage SERVEUR (suit l'usager entre appareils)
const boardView = ref(['kanban', 'routes'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'routes'
const boardView = ref(['kanban', 'routes', 'month'].includes(planifPrefs.value.view) ? planifPrefs.value.view : 'grid') // 'grid' | 'kanban' | 'month' | 'routes'
watch(boardView, (v) => { savePlanif({ view: v }); if (v === 'kanban') { kbSelIso.value = nowET.value.iso; if (!dayList.value.find(d => d.iso === kbSelIso.value)) { start.value = thisMonday(); loadWeek() } _kbDidScroll = false; nextTick(() => setTimeout(kbScrollToDefault, 160)) } else if (v === 'grid' && days.value < 7) { days.value = 7; loadWeek() } })
watch(() => planifPrefs.value.view, (v) => { if (v && v !== boardView.value) boardView.value = v }) // sync depuis le serveur (autre appareil) une fois chargé
const kbSelIso = ref('') // mode Jour : jour explicitement choisi ('' = auto aujourd'hui)
@ -2385,7 +2329,6 @@ function exportGpx (techId) {
}
// 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: '', customerId: '', 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: '' })
function openExternal (url) { if (url) window.open(url, '_blank', 'noopener') } // ouvre le doctype dans ERPNext (chrome harmonisé avec DetailModal)
// Décode les entités HTML (« d&#039;é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 }
@ -2555,14 +2498,13 @@ function dropAskDays () {
if (dropAsk.scope === 'week') { const m = mondayISO(dropAsk.day.iso); return Array.from({ length: 7 }, (_, i) => addDaysISO(m, i)) }
const out = []; let dd = dropAsk.from; let g = 0; while (dd && dropAsk.to && dd <= dropAsk.to && g++ < 400) { out.push(dd); dd = addDaysISO(dd, 1) }; return out
}
async function dropAskAbsence () {
function dropAskAbsence () {
const t = dropAsk.tech; const days = dropAskDays(); if (!days.length) { $q.notify({ type: 'warning', message: 'Plage de dates invalide.' }); return }
for (const dd of days) { try { await roster.setAbsence(t.id, dd, dropAsk.absType || 'Congé', false) } catch (e) { err(e) } } // séquentiel (frappe_pg)
await reloadAbsences(); await reloadOccupancy()
pushHistory()
for (const dd of days) { const k = t.id + '|' + dd; const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null; setAbsPending(k, dropAsk.absType || 'Congé'); logAbsChange('Congé ajouté · ' + dd, k, beforeVal) } // LOCAL à Publier (IROPS au moment de Publier)
const retour = addDaysISO(days[days.length - 1], 1)
const noms = dropAsk.names.slice(); dropAsk.open = false
$q.notify({ type: 'warning', icon: 'event_busy', message: `${t.name} absent ${days.length} j (${dropAsk.absType}) · retour le ${retour}. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] })
await checkAbsenceImpact(days.map(dd => t.id + '|' + dd)) // redistribue les jobs DÉJÀ assignés sur ces jours (dialogue existant)
const noms = dropAsk.names.slice(); dropAsk.open = false; scheduleDraftSave()
$q.notify({ type: 'warning', icon: 'event_busy', message: `${t.name} absent ${days.length} j (${dropAsk.absType}) en attente · retour le ${retour}. Publier pour appliquer. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] })
}
// Absence sur PLAGE (comme l'ancien dispatch) : défaut = jour affiché ; options « ce jour / cette semaine / plage du..au ».
const absDialog = reactive({ open: false, techId: '', techName: '', from: '', to: '' })
@ -2573,13 +2515,12 @@ function openAbsDialog () {
}
function absWeek () { const m = mondayISO(absDialog.from || todayISO()); absDialog.from = m; absDialog.to = addDaysISO(m, 6) }
function absDays () { const out = []; let d = absDialog.from; if (!d || !absDialog.to || absDialog.to < d) return d ? [d] : out; let g = 0; while (d <= absDialog.to && g++ < 400) { out.push(d); d = addDaysISO(d, 1) }; return out }
async function applyAbs (remove) {
function applyAbs (remove) {
const days = absDays(); if (!days.length || !absDialog.techId) return
for (const d of days) { try { await roster.setAbsence(absDialog.techId, d, 'Congé', remove) } catch (e) { err(e) } } // séquentiel (frappe_pg)
await reloadAbsences(); await reloadOccupancy()
$q.notify({ type: 'info', message: remove ? ('Absence retirée (' + days.length + ' j)') : (days.length + ' jour(s) marqué(s) absent') })
absDialog.open = false
if (!remove) await checkAbsenceImpact(days.map(d => absDialog.techId + '|' + d))
pushHistory(); let n = 0
for (const d of days) { const k = absDialog.techId + '|' + d; const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null; setAbsPending(k, remove ? null : 'Congé'); logAbsChange((remove ? 'Congé retiré · ' : 'Congé ajouté · ') + d, k, beforeVal); n++ } // LOCAL à Publier
$q.notify({ type: 'info', message: (remove ? ('Absence retirée (' + n + ' j)') : (n + ' jour(s) marqué(s) absent')) + ' — Publier pour appliquer' })
absDialog.open = false; scheduleDraftSave()
}
// Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option
@ -2677,7 +2618,6 @@ watch(jdShowTrack, (on) => { if (!_jdMap) return; if (on) loadJdTrack(); else {
watch(() => jobDetail.open, (open) => { if (!open) { jdShowMap.value = false; if (_jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } } })
watch(jdShowMap, (on) => { if (on && jobDetail.open) nextTick(() => setTimeout(initJdMap, 200)); else if (!on && _jdMap) { try { _jdMap.remove() } catch (e) {} _jdMap = null } })
const tplOptions = computed(() => templates.value.map(t => ({ label: t.template_name, value: t.name })))
const techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
// CALQUE de garde LIVE
@ -2772,8 +2712,39 @@ async function applyWeekPreset (t, dows, min, max) {
}
// Écran unique « Horaire » par tech (icône event de la rangée) : pause indéfinie + horaire récurrent + calendrier congés + archivage.
const techSchedOpen = ref(false); const techSchedTech = ref(null)
const techSchedRefresh = ref(0) // après une écriture de quart le calendrier par tech recharge son mois
function openTechSchedule (t) { techSchedTech.value = t; techSchedOpen.value = true }
// Calendrier par tech : le menu de cellule PARTAGÉ y émet des intentions ; on les exécute avec les mêmes fonctions que la grille.
// Quarts = persistés PAR DATE (createShift 'Publié' / deleteAssignment) car le calendrier est mensuel (hors fenêtre de la grille).
async function onTechSchedSetShift ({ techId, iso, min, max } = {}) {
if (!techId || !iso || !(max > min)) return
const t = (techs.value || []).find(x => x.id === techId); const tpl = await ensureWindowTpl(min, max); if (!tpl) return
try {
// Remplace : retire les quarts réguliers existants de ce jour, puis pose le nouveau.
const existing = (await roster.listAssignments(iso, 1)).assignments || []
for (const a of existing) { const tp = tplByName.value[a.shift]; if (a.tech === techId && a.date === iso && tp && !tp.on_call) { try { await roster.deleteAssignment(a.name) } catch (e) {} } }
await roster.createShift({ tech: techId, tech_name: t ? t.name : '', date: iso, shift: tpl.name, zone: tpl.zone || '', hours: tpl.hours || 8 })
$q.notify({ type: 'positive', message: `Quart ${fmtH(min)}h${fmtH(max)}h · ${iso}`, timeout: 1800 })
} catch (e) { err(e) }
techSchedRefresh.value++; await loadWeek()
}
async function onTechSchedRemoveShift ({ name } = {}) { if (!name) return; try { await roster.deleteAssignment(name); $q.notify({ type: 'info', message: 'Quart retiré', timeout: 1500 }) } catch (e) { err(e) } techSchedRefresh.value++; await loadWeek() }
async function onTechSchedClearShifts ({ names } = {}) { if (!names || !names.length) return; for (const n of names) { try { await roster.deleteAssignment(n) } catch (e) {} } $q.notify({ type: 'info', message: 'Quarts retirés', timeout: 1500 }); techSchedRefresh.value++; await loadWeek() }
function onTechSchedToggleGarde ({ techId, iso } = {}) { if (techId && iso) toggleGardeCells([techId + '|' + iso]) } // garde = manualGarde (localStorage), clé tech|iso cross-fenêtre OK
function onTechSchedEdit (t) { techSchedOpen.value = false; openSchedGen(t) } // « Modifier l'horaire récurrent » réutilise WeeklyScheduleEditor existant
// Calendrier par tech : les congés y sont STAGÉS (publish-required), comme dans la grille. { techId, changes:[{iso, type}] } (type '' = retrait).
function onStageAbs ({ techId, changes } = {}) {
if (!techId || !Array.isArray(changes) || !changes.length) return
pushHistory(); let n = 0
for (const c of changes) {
const k = techId + '|' + c.iso; const wantType = c.type || null
if ((effAbsType(techId, c.iso) || null) === wantType) continue // déjà dans l'état voulu
const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null
setAbsPending(k, wantType)
logAbsChange((wantType ? (wantType + ' · ') : 'Congé retiré · ') + c.iso, k, beforeVal); n++
}
if (n) { $q.notify({ type: 'info', message: n + ' changement(s) de congé en attente — Publier pour appliquer', timeout: 2200 }); scheduleDraftSave() }
}
async function onTechSchedChanged (ev) {
// pause : reflète le statut localement ; archivage : recharge la base (le tech disparaît). Congés : recharge la semaine (hachures).
if (ev && ev.status) { const tt = techs.value.find(x => x.id === ev.id); if (tt) tt.status = ev.status }
@ -2846,13 +2817,6 @@ function skillEffColor (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; retur
// Icône de compétence pour q-icon (accepte les SYMBOLES SVG importés) : échelle=installation, casque=support,
// outils=réparation ; sinon ligature classique unique (skillIcon). NB: les pins carte (HTML brut) gardent skillIcon.
function tSkills (t) { return [...(t.skills || [])].sort((a, b) => a.localeCompare(b)) } // compétences triées du tech (pour l'affichage 1 + « +N »)
function skillSym (sk) {
const s = String(sk || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv' // télé/TV TV AVANT « install » (« Install/Reparation Télé » contient « install »)
if (/install/.test(s)) return 'router' // installation fibre = pose du routeur/ONT (icône unique carte + listes)
if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic
return skillIcon(sk) // réparation → 'build' (clé simple / wrench) via skillIcon ; autres ligature unique
}
// Icône d'un bloc de tournée (vue JOUR et SEMAINE) : renfort group ; sinon icône de COMPÉTENCE
// (skill/required_skill, ou dept pour les tickets legacy ex. « Install/Reparation Télé » = TV).
// Le ticket générique n'est utilisé qu'en dernier recours (legacy sans skill ni dept).
@ -2863,41 +2827,7 @@ function blkIcon (b) {
if (sk) return skillSym(sk)
return (b && b.legacy) ? 'confirmation_number' : skillSym('')
}
// Camion-nacelle (bucket truck) pour « monteur » Material Symbols n'a pas d'équivalent SVG custom au format
// q-icon Quasar (chemins séparés par && ; chaque chemin = d@@style). Aplats (carrosserie/roues/nacelle, roues
// évidées en evenodd) + bras articulé en trait épais. Hérite couleur (currentColor) et taille. viewBox 512×416.
const BUCKET_TRUCK = 'M120,300 H440 V322 H120 Z M120,276 H310 V300 H120 Z M206,226 H280 V300 H206 Z M56,64 H160 V144 H56 Z@@fill:currentColor'
+ '&&M300,300 V250 L322,222 H408 L438,260 V300 Z M330,252 H396 V284 H330 Z@@fill:currentColor;fill-rule:evenodd'
+ '&&M144,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M170,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z M352,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M378,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z@@fill:currentColor;fill-rule:evenodd'
+ '&&M243,250 L384,150 L120,104@@fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round'
+ '|0 0 512 416'
function skillIcon (sk) {
const s = String(sk || '').toLowerCase()
if (/install/.test(s)) return 'construction'
if (/r[ée]par|d[ée]pann|bris/.test(s)) return 'build'
if (/fibre|fusion|soud|épissure|epissure/.test(s)) return 'cable'
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv'
if (/t[ée]l[ée]phon|voip|\b3cx\b/.test(s)) return 'call'
if (/r[ée]seau|net\s?admin|router|bgp|olt|acs/.test(s)) return 'dns'
if (/\bwifi\b|wi-?fi|mesh/.test(s)) return 'wifi' // wifi MAISON (couverture interne) symbole wifi simple
if (/sans.?fil|wireless|\bfwa\b|\blte\b|radio|antenne|\btour\b|micro.?onde|point.?[àa].?point|\bptp\b|liaison/.test(s)) return 'cell_tower' // sans fil = PtP inter-bâtiments/tours tour
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return BUCKET_TRUCK // monteur / aérien camion-nacelle
if (/d[ée]sinstall|retrait|ramass|d[ée]mant/.test(s)) return 'delete_sweep'
if (/info|ordinateur|\bpc\b|cam[ée]ra/.test(s)) return 'computer'
if (/vente|sales|soumission/.test(s)) return 'sell'
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
return 'handyman' // visite générique (type non reconnu) : technicien avec outils, plutôt que l'éclair peu parlant
}
// Icône de compétence pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures material (pas les SVG
// custom de skillSym) sinon le glyphe ne s'affiche pas dans un <span class="material-icons"> hors q-icon.
function markerIcon (skill) {
const s = String(skill || '').toLowerCase()
if (/t[ée]l[ée]vis|\btv\b|iptv/.test(s)) return 'live_tv'
if (/install/.test(s)) return 'router' // installation fibre = pose du routeur/ONT au domicile
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return 'local_shipping'
const ic = skillIcon(skill)
return (typeof ic === 'string' && /^[a-z0-9_]+$/.test(ic)) ? ic : 'build'
}
// (skillIcon / skillSym / markerIcon / BUCKET_TRUCK composables/useSkillIcons.js SOURCE UNIQUE réutilisable)
function onTagsChange (t, items) {
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
const removed = (t.skills || []).filter(s => !newLabels.includes(s)) // compétences retirées vérifier l'impact sur les jobs assignés
@ -4169,9 +4099,10 @@ const currentSet = computed(() => new Set(assignments.value.map(a => a.tech + '|
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 })
// #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)))
// « dirty » = quarts NON PUBLIÉS + congés en attente (publish-required) tout doit passer par « Publier ».
const dirty = computed(() => unpublished.value.length > 0 || pendingAbsCount.value > 0)
const dirtyCount = computed(() => unpublished.value.length + pendingAbsCount.value)
const dirtyCells = computed(() => { const s = new Set(unpublished.value.map(a => a.tech + '|' + a.date)); for (const k of Object.keys(pendingAbs.value)) s.add(k); return s })
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é' })
@ -4217,18 +4148,50 @@ watch([boardView, kanbanDay, occByTechDay], () => {
if (boardView.value !== 'kanban') return
clearTimeout(_kbMatT); _kbMatT = setTimeout(() => { for (const t of visibleTechs.value) fetchKbMatrix(t.id) }, 350) // 1 call/tournée visible, débouncé + caché
})
const absByTechDay = ref({}) // tech|date → type d'absence (En pause / Congé / Maladie) hachuré
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' }
const absByTechDay = ref({}) // tech|date → type d'absence SERVEUR (En pause / Congé / Maladie) hachuré
// #congé PUBLISH-REQUIRED : couche LOCALE d'absences en attente (delta vs serveur), écrite au serveur SEULEMENT à « Publier ».
// clé 'tech|iso' { op:'set', type } (marquer absent) | { op:'remove' } (retirer une absence serveur). Persistée localStorage (survie au rechargement).
const LS_PENDING_ABS = 'roster-pending-abs-v1'
const pendingAbs = ref({})
function savePendingAbs () { try { localStorage.setItem(LS_PENDING_ABS, JSON.stringify(pendingAbs.value)) } catch (e) {} }
const pendingAbsCount = computed(() => Object.keys(pendingAbs.value).length)
// Type d'absence EFFECTIF = état serveur écrasé par le delta local en attente. null = pas absent.
function effAbsType (techId, iso) { const k = techId + '|' + iso; const p = pendingAbs.value[k]; if (p) return p.op === 'set' ? (p.type || 'Congé') : null; return absByTechDay.value[k] || null }
function isAbsent (techId, iso) { return !!effAbsType(techId, iso) }
function absenceLabel (techId, iso) { return effAbsType(techId, iso) || 'Absent' }
async function reloadAbsences () { try { const r = await roster.getAbsences(start.value, days.value); absByTechDay.value = r.absences || {} } catch (e) {} }
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Si toutes absentes retire ; sinon marque.
async function toggleAbsentCells (targets) {
// Pose le delta d'absence d'une cellule (wantType=null non absent). N'écrit PAS au serveur (publish-required).
// Si l'état voulu == état serveur on efface le delta (pas de changement en attente inutile).
function setAbsPending (key, wantType) {
const serverType = absByTechDay.value[key] || null
const m = { ...pendingAbs.value }
if ((wantType || null) === serverType) delete m[key]
else m[key] = wantType ? { op: 'set', type: wantType } : { op: 'remove' }
pendingAbs.value = m; savePendingAbs()
}
// Écrit une entrée d'absence dans le journal (avec revert du delta précédent) annulable comme un quart.
function logAbsChange (text, key, beforeVal) { logChange(text, { absKey: key, beforeVal }) }
// Le tech travaille-t-il ce jour selon son patron hebdo (weekly_schedule) ? null = pas de patron inconnu (on garde l'estimation 8h).
function patternWorksDay (techId, iso) {
const t = (techs.value || []).find(x => x.id === techId); const sched = t && t.weekly_schedule
if (!sched || typeof sched !== 'object') return null
const day = sched[['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'][new Date(iso + 'T12:00:00Z').getUTCDay()]]
return !!(day && day.start && day.end)
}
// Bascule absence d'1 jour sur des cases (clic + « A » ou menu). Toutes absentes retire ; sinon marque. LOCAL à Publier.
function toggleAbsentCells (targets) {
if (!targets || !targets.length) return
const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) })
for (const k of targets) { const [tid, iso] = k.split('|'); try { await roster.setAbsence(tid, iso, 'Congé', allAbsent) } catch (e) { err(e) } }
await reloadAbsences()
$q.notify({ type: 'info', message: allAbsent ? 'Absence retirée' : (targets.length + ' absence(s) marquée(s)') })
if (!allAbsent) await checkAbsenceImpact(targets) // marquage vérifier les jobs assignés impactés (IROPS)
const want = !allAbsent
pushHistory(); let n = 0
for (const k of targets) {
const [tid, iso] = k.split('|')
if (!!effAbsType(tid, iso) === want) continue // déjà dans l'état voulu (toggle idempotent)
const beforeVal = pendingAbs.value[k] ? { ...pendingAbs.value[k] } : null
setAbsPending(k, want ? 'Congé' : null)
logAbsChange((want ? 'Congé ajouté · ' : 'Congé retiré · ') + iso, k, beforeVal); n++
}
if (n) { $q.notify({ type: 'info', message: (want ? (n + ' congé(s) en attente') : (n + ' congé(s) retiré(s)')) + ' — Publier pour appliquer' }); scheduleDraftSave() }
}
function saveManualGarde () { localStorage.setItem(LS_GARDE_MANUAL, JSON.stringify(manualGarde.value)) }
// Override manuel : ne stocke QUE les écarts vs règles (want===défaut-règle on retire l'override) carte minimale.
@ -4416,11 +4379,15 @@ const selDayLabel = computed(() => { const d = mobileSelDayObj.value; return d &
// Charge d'un tech : heures occupées / capacité. Dénominateur = heures du QUART RÉEL s'il est planifié ;
// SINON journée nominale ESTIMÉE à 8h (le tech a du travail mais aucun quart on le signale avec noShift=).
// 3h sans quart 3/8 37% (vert) ; rouge seulement si réellement au-dessus de la capacité.
function techLoad (o, hasShift) {
// #4 capacité par tech-jour. Le tech ABSENT (congé/vacances) = 0h dispo (bug « 48h malgré des vacances »).
// Sans quart : estimation 8h SEULEMENT un jour où il travaille selon son patron ; jour off du patron = 0h (bug « 48h alors qu'il fait 5×8h »).
function techLoad (o, hasShift, techId, iso) {
const h = +(o && o.usedH) || 0
const book = +(o && o.bookableH) || 0
const cap = (hasShift && book > 0) ? book : 8
return { h: Math.round(h * 10) / 10, cap: Math.round(cap * 10) / 10, ratio: cap > 0 ? h / cap : 0, over: h > cap + 0.01, noShift: !hasShift }
const absent = (techId && iso) ? isAbsent(techId, iso) : false
const works = (techId && iso) ? patternWorksDay(techId, iso) : null // null = patron inconnu on garde l'estimation
const cap = absent ? 0 : ((hasShift && book > 0) ? book : (works === false ? 0 : 8))
return { h: Math.round(h * 10) / 10, cap: Math.round(cap * 10) / 10, ratio: cap > 0 ? h / cap : 0, over: cap > 0 && h > cap + 0.01, noShift: !hasShift && !absent && works !== false, absent }
}
// Barre globale du jour = Σ durées jobs estimées (numérateur) / Σ durées des quarts (dénominateur) = couverture du jour.
const mobileDayLoads = computed(() => {
@ -4431,9 +4398,10 @@ const mobileDayLoads = computed(() => {
for (const t of ts) {
const o = techDayOcc(t.id, d.iso); if (!o) continue
const u = +o.usedH || 0; const bk = +o.bookableH || 0; const hasSh = hasShiftDay(t.id, d.iso)
const cap = (hasSh && bk > 0) ? bk : 8 // quart réel sinon estimation 8h (cohérent avec les barres par tech)
assignedH += u; capH += cap; if (u > cap + 0.01) over++
if (!hasSh && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié
const absent = isAbsent(t.id, d.iso); const works = patternWorksDay(t.id, d.iso)
const cap = absent ? 0 : ((hasSh && bk > 0) ? bk : (works === false ? 0 : 8)) // absent = 0h ; jour off du patron = 0h ; sinon quart réel ou estimation 8h
assignedH += u; capH += cap; if (cap > 0 && u > cap + 0.01) over++
if (!absent && !hasSh && works !== false && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié (et pas en congé / jour off)
}
// Numérateur = TOUT le travail DÛ ce jour à répartir = jobs déjà assignés ce jour (assignedH) + jobs du POOL (non assignés) dont la date prévue est ce jour.
let pendingH = 0
@ -4445,7 +4413,7 @@ const mobileDayLoads = computed(() => {
})
const mobileTechLoads = computed(() => {
const iso = mobileSelDay.value
return (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) })).sort((a, b) => b.h - a.h)
return (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso) })).sort((a, b) => b.h - a.h)
})
const pmShowIdle = ref(false) // mobile : replier les techniciens à 0 h (sinon on scrolle 50+ barres vides)
const mobileTechBusy = computed(() => mobileTechLoads.value.filter(t => t.h > 0))
@ -4467,7 +4435,7 @@ const mobileTechAssign = computed(() => {
const capable = (t) => !act.size || (t.skills || []).some(s => act.has(s))
// Candidats CAPABLES : ceux qui ONT déjà un quart d'abord (prêts, pas de création requise), puis les moins chargés.
// Les « sans quart » () restent listés en dessous le dispatcher peut quand même les toucher et accepter la création d'un quart.
return (visibleTechs.value || []).filter(capable).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso)) })).sort((a, b) => (a.noShift ? 1 : 0) - (b.noShift ? 1 : 0) || a.h - b.h)
return (visibleTechs.value || []).filter(capable).map(t => ({ id: t.id, name: t.name, _t: t, ...techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso) })).sort((a, b) => (a.noShift ? 1 : 0) - (b.noShift ? 1 : 0) || a.h - b.h)
})
// TAP-TO-ASSIGNER (desktop) : clic sur une job du pool techs CLASSÉS par pertinence pour CETTE job, 1 clic assigne
@ -4496,7 +4464,7 @@ function techsForJob (job) {
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 load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso)
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
@ -4525,7 +4493,7 @@ function techsForSelection () {
const cLon = ll.length ? ll.reduce((s, j) => s + (+j.lon), 0) / ll.length : null
const iso = jobTargetDay(jobs[0]) // jour de référence pour la charge (approx.)
return (visibleTechs.value || []).map(t => {
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso))
const load = techLoad(techDayOcc(t.id, iso), hasShiftDay(t.id, iso), t.id, iso)
const capable = !reqSkills.some(s => !(t.skills || []).includes(s)) // capable de TOUTES les compétences de la sélection
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
const distKm = (cLat != null && home) ? haversineKm(home.lat, home.lon, cLat, cLon) : null
@ -4747,7 +4715,7 @@ function buildSuggestion () {
const isOver = j => (j.scheduled_date && j.scheduled_date !== 'Sans date' && j.scheduled_date < today) ? 0 : 1
const sorted = [...jobs].sort((a, b) => pr(a) - pr(b) || isOver(a) - isOver(b) || String(a.scheduled_date || '9').localeCompare(String(b.scheduled_date || '9')) || jobDur(b) - jobDur(a))
const proj = {} // techId|iso { h, cap, pts:[[lat,lon]] } charge PROJETÉE, initialisée depuis l'occupation actuelle
const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso)); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] }
const cell = (tid, iso) => { const k = tid + '|' + iso; if (!proj[k]) { const load = techLoad(techDayOcc(tid, iso), hasShiftDay(tid, iso), tid, iso); proj[k] = { h: load.h || 0, cap: load.cap || 8, pts: [], cities: new Set() } } return proj[k] }
// Poids selon la STRATÉGIE : smart · best (meilleurs d'abord, cascade) · balance (round-robin) · enough (juste ce qu'il faut réserve les experts)
const strat = suggestDlg.strategy || 'smart'
// rank = ORDRE de la compétence chez le tech (0 = compétence PRINCIPALE) un job va d'abord aux spécialistes de CETTE compétence, épargnant les polyvalents.
@ -5169,7 +5137,7 @@ const suggestGroups = computed(() => {
const placeholder = isPh
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
if (!placeholder) for (const d of days) {
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso))
const l = techLoad(techDayOcc(g.techId, d.iso), hasShiftDay(g.techId, d.iso), g.techId, d.iso)
const dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0)
const pct = dcap ? dproj / dcap * 100 : 0
if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap }
@ -5706,11 +5674,12 @@ function covCell (key, iso) { return covByKeyDay.value[key + '|' + iso] }
function covText (key, iso) { const c = covCell(key, iso); return c ? (c.assigned + '/' + c.required) : '' }
function covStyle (key, iso) { const c = covCell(key, iso); if (!c) return {}; return c.shortfall > 0 ? { background: '#ffcdd2', color: '#b71c1c', fontWeight: 700 } : { background: '#c8e6c9', color: '#1b5e20' } }
// undo / redo
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
// undo / redo capture les quarts ET les congés en attente (les 2 sont annulables via Ctrl+Z).
function snap () { return { a: JSON.parse(JSON.stringify(assignments.value)), p: JSON.parse(JSON.stringify(pendingAbs.value)) } }
function restoreSnap (s) { if (Array.isArray(s)) { assignments.value = s; return } assignments.value = s.a || []; pendingAbs.value = s.p || {}; savePendingAbs() }
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(); logChange('↶ Annulé'); scheduleDraftSave() }
function redo () { if (!future.value.length) return; history.value.push(snap()); assignments.value = future.value.pop(); logChange('↷ Rétabli'); scheduleDraftSave() }
function undo () { if (!history.value.length) return; future.value.push(snap()); restoreSnap(history.value.pop()); logChange('↶ Annulé'); scheduleDraftSave() }
function redo () { if (!future.value.length) return; history.value.push(snap()); restoreSnap(future.value.pop()); logChange('↷ Rétabli'); scheduleDraftSave() }
// garde anti-perte
// #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).
@ -5792,15 +5761,31 @@ async function doGenerate () {
}
// #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 }
// #congé publish-required : commit les deltas d'absence en attente (create/remove Tech Availability), SÉQUENTIEL (frappe_pg).
// Retourne le nb écrit + les clés nouvellement mises en congé (pour l'impact IROPS, une fois l'absence réellement en vigueur).
async function flushPendingAbsences () {
const entries = Object.entries(pendingAbs.value); const newlyAbsent = []; let n = 0
for (const [k, v] of entries) {
const [tid, iso] = k.split('|')
try {
if (v.op === 'set') { await roster.setAbsence(tid, iso, v.type || 'Congé', false); newlyAbsent.push(k); n++ }
else { await roster.setAbsence(tid, iso, '', true); n++ }
} catch (e) { err(e) }
}
pendingAbs.value = {}; savePendingAbs()
return { n, newlyAbsent }
}
async function doPublishConfirmed () {
publishing.value = true
try {
// 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` : '') })
const abs = await flushPendingAbsences() // congés en attente serveur
$q.notify({ type: r.errors ? 'warning' : 'positive', message: `Publié : ${done} quart(s)` + (abs.n ? ` · ${abs.n} congé(s)` : '') + (r.deleted ? ` (${r.deleted} retirés)` : '') + (r.errors ? ` · ${r.errors} erreurs` : '') + (r.notified ? ` · ${r.notified} SMS` : '') })
pubConfirm.value = false
await loadWeek()
if (abs.newlyAbsent.length) await checkAbsenceImpact(abs.newlyAbsent) // IROPS : redistribue les jobs des jours désormais en congé
} 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).
@ -5815,7 +5800,7 @@ async function doWeekStatus (mode) {
}
// demande
function loadLS () { try { demand.value = JSON.parse(localStorage.getItem(LS_DEMAND) || '[]') } catch { demand.value = [] } demand.value.forEach(d => { if (!Array.isArray(d.skills)) d.skills = String(d.skills || '').split(',').map(s => s.trim()).filter(Boolean) }); /* migration CSV→tableau (ancien SkillSelect) */ try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } }
function loadLS () { try { holidays.value = JSON.parse(localStorage.getItem(LS_HOL) || '[]') } catch { holidays.value = [] } try { weekTemplates.value = JSON.parse(localStorage.getItem(LS_TPL) || '[]') } catch { weekTemplates.value = [] } try { gardeRules.value = JSON.parse(localStorage.getItem(LS_GARDE) || '[]') } catch { gardeRules.value = [] } try { manualGarde.value = JSON.parse(localStorage.getItem(LS_GARDE_MANUAL) || '{}') } catch { manualGarde.value = {} } try { customTags.value = JSON.parse(localStorage.getItem('roster-skill-tags-v1') || '[]') } catch { customTags.value = [] } try { hiddenTechs.value = JSON.parse(localStorage.getItem('roster-hidden-techs-v1') || '[]') } catch { hiddenTechs.value = [] } try { pendingAbs.value = JSON.parse(localStorage.getItem(LS_PENDING_ABS) || '{}') } catch { pendingAbs.value = {} } }
// Rotation de garde par département (récurrence + rotation)
const GARDE_EPOCH = '2026-01-05' // lundi de référence pour l'index de semaine
@ -5908,30 +5893,8 @@ async function applyGardeRules () {
$q.notify({ type: 'positive', message: `Garde publiée sur ${weeks} sem. : ${r.created} assignations` + (r.deleted ? ` (${r.deleted} remplacées)` : '') + '. La grille la montrait déjà en direct ; c\'est maintenant visible par dispatch et les techs.', timeout: 6000 })
} catch (e) { err(e) }
}
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
function onDemandSkills (d, items) { d.skills = normSkillList(items); saveDemand() } // TagEditor émet un tableau ; d.skills reste un tableau ( CSV à la génération)
function addDemand () { demand.value = [...demand.value, { shift: templates.value[0] && templates.value[0].name, zone: 'Montréal', skills: [], job_h: 0, weekday: 1, weekend: 0, holiday: 0 }]; saveDemand() }
function removeDemand (i) { demand.value = demand.value.filter((_, j) => j !== i); saveDemand() }
async function applyDemand () {
if (!demand.value.length) { $q.notify({ type: 'warning', message: 'Aucune ligne de demande' }); return }
applying.value = true
try {
await roster.clearRequirements(start.value, days.value)
const reqs = []
for (const d of dayList.value) {
const slot = isHoliday(d.iso) ? 'holiday' : (d.weekend ? 'weekend' : 'weekday')
for (const row of demand.value) {
const n = Number(row[slot]) || 0; if (n <= 0 || !row.shift) continue
const jobH = Number(row.job_h) || 0
const sh = (tplByName.value[row.shift] && tplByName.value[row.shift].hours) || 8
const count = jobH > 0 ? Math.max(1, Math.ceil(n * jobH / sh)) : n // mode jobs effectif
reqs.push({ requirement_date: d.iso, shift_template: row.shift, zone: row.zone || '', required_count: count, required_skills: normSkillList(row.skills).join(',') })
}
}
if (reqs.length) await roster.bulkRequirements(reqs)
await loadWeek(); $q.notify({ type: 'positive', message: 'Demande appliquée : ' + reqs.length + ' besoins' })
} catch (e) { err(e) } finally { applying.value = false }
}
// « Demande » (besoins par template) RETIRÉ remplacé par la vue Mois (MonthOverview) : besoins EN HEURES par compétence,
// couverture + alertes, et génération des Shift Requirements pour le solveur. Voir components/planif/MonthOverview.vue.
// modèles de semaine
function saveTemplate () {
@ -5998,10 +5961,16 @@ function logChange (text, revert) { changeLog.value.unshift({ id: ++_chgSeq, at:
// Annule UN changement du journal : restaure la cellule (tech, date) à son état d'avant. L'annulation est elle-même dans l'historique global (undo).
function revertChange (c) {
if (!c || !c.revert || c.reverted) return
const { tech, date, before } = c.revert
pushHistory()
const others = assignments.value.filter(a => !(a.tech === tech && a.date === date))
assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))]
if (c.revert.absKey) { // congé : restaure le delta d'absence précédent de la cellule
const m = { ...pendingAbs.value }
if (c.revert.beforeVal) m[c.revert.absKey] = c.revert.beforeVal; else delete m[c.revert.absKey]
pendingAbs.value = m; savePendingAbs()
} else {
const { tech, date, before } = c.revert
const others = assignments.value.filter(a => !(a.tech === tech && a.date === date))
assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))]
}
c.reverted = true
logChange('↶ Annulé · ' + c.text)
$q.notify({ type: 'info', message: 'Changement annulé', timeout: 1600 })
@ -6076,17 +6045,15 @@ async function applyWindow (min, max) {
const tpl = await ensureWindowTpl(min, max)
if (tpl) { pushHistory(); setCellReplace(menu.tech.id, menu.tech.name, menu.day.iso, tpl); menu.show = false }
}
function quickShift (min, max) { return applyWindow(min, max) }
async function applyMenuRange () { return applyWindow(menuRange.value.min, menuRange.value.max) }
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=817, dernier chiffre en pm si début).
function parseHM (tok) { tok = String(tok).trim().toLowerCase().replace(/h/g, ':').replace(/[^\d:]/g, ''); if (!tok) return null; if (tok.includes(':')) { const [h, m] = tok.split(':'); return Number(h) + (Number(m || 0)) / 60 } if (tok.length >= 3) return Number(tok.slice(0, -2)) + Number(tok.slice(-2)) / 60; return Number(tok) }
function parseQuickShift (str) {
const s = (str || '').trim().toLowerCase(); if (!s) return null
if (/[-–—]|to|→|\s/.test(s)) { const p = s.split(/[-–—]|to|→|\s+/).filter(Boolean); if (p.length < 2) return null; const a = parseHM(p[0]); const b = parseHM(p[1]); return (a == null || b == null || b <= a || b > 24) ? null : { min: a, max: b } }
if (/^\d{3,4}$/.test(s)) { const end = Number(s.slice(-2)); const start = Number(s.slice(0, -2)); if (end <= 24 && start >= 0 && start < end) return { min: start, max: end } } // « 816 »=816 · « 1016 »=1016
if (/^\d{2}$/.test(s)) { const a = Number(s[0]); let b = Number(s[1]); if (b <= a) b += 12; return (b <= a || b > 24) ? null : { min: a, max: b } } // « 85 » = 817
return null
}
function applyQuick () { const r = parseQuickShift(quickEntry.value); if (!r) { $q.notify({ type: 'warning', message: 'Format : 8-17 · 8:30-16 · 85' }); return } quickEntry.value = ''; applyWindow(r.min, r.max) }
function assignBulk (tpl) { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); addShift(tid, t ? t.name : tid, iso, tpl) } selection.value = [] }
// Barre de sélection : mêmes 4 actions que le menu de cellule
async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) }