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:
parent
0853947c43
commit
3489576212
252
apps/ops/src/components/planif/MonthOverview.vue
Normal file
252
apps/ops/src/components/planif/MonthOverview.vue
Normal 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>
|
||||||
79
apps/ops/src/components/planif/PlanifCellMenu.vue
Normal file
79
apps/ops/src/components/planif/PlanifCellMenu.vue
Normal 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 8–16" class="col" @click="$emit('window', { min: 8, max: 16 })" />
|
||||||
|
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 16–20" 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 (=8→17) -->
|
||||||
|
<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 » (=8→17, 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 »=8–16 · « 1016 »=10–16 · « 0816 »=8–16 (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 »=8→17
|
||||||
|
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>
|
||||||
|
|
@ -53,14 +53,15 @@
|
||||||
<span v-for="b in BRUSHES" :key="b.type" class="ts-brush" :class="{ on: brush === b.type }"
|
<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 }"
|
: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>
|
@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>
|
</div>
|
||||||
<!-- Grille -->
|
<!-- Grille — clic simple = menu de cellule PARTAGÉ (Jour/Soir/Garde/Absent/heures, comme la semaine/jour) ;
|
||||||
<div class="ts-cal" @mouseleave="endDrag" @mouseup="endDrag">
|
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="d in DOW_LABELS" :key="'h' + d" class="ts-dowh">{{ d }}</div>
|
||||||
<div v-for="(c, i) in monthCells" :key="i"
|
<div v-for="(c, i) in monthCells" :key="i"
|
||||||
class="ts-cell" :class="cellClass(c)"
|
class="ts-cell" :class="cellClass(c)"
|
||||||
@mousedown.prevent="c.inMonth && startDrag(c)"
|
@mousedown.prevent="c.inMonth && startDrag(c, $event)"
|
||||||
@mouseenter="c.inMonth && overDrag(c)">
|
@mouseenter="c.inMonth && overDrag(c)">
|
||||||
<template v-if="c.inMonth">
|
<template v-if="c.inMonth">
|
||||||
<span class="ts-daynum">{{ c.day }}</span>
|
<span class="ts-daynum">{{ c.day }}</span>
|
||||||
|
|
@ -74,6 +75,16 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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 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>
|
<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 { useQuasar } from 'quasar'
|
||||||
import * as roster from 'src/api/roster'
|
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 { 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({
|
const props = defineProps({
|
||||||
modelValue: { type: Boolean, default: false },
|
modelValue: { type: Boolean, default: false },
|
||||||
tech: { type: Object, default: null }, // { id, name, status, ... }
|
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 $q = useQuasar()
|
||||||
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
|
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 calLoading = ref(false)
|
||||||
const absMap = ref({}) // 'YYYY-MM-DD' → type d'absence
|
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 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 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)
|
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 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 })
|
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' }) }
|
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 iso = y + '-' + pad(m) + '-' + pad(day)
|
||||||
const dow = new Date(iso + 'T12:00:00').getUTCDay()
|
const dow = new Date(iso + 'T12:00:00').getUTCDay()
|
||||||
const sh = shiftMap.value[iso]
|
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 })
|
while (cells.length % 7) cells.push({ inMonth: false })
|
||||||
return cells
|
return cells
|
||||||
|
|
@ -195,10 +224,11 @@ async function loadCal () {
|
||||||
const [y, m] = month.value.split('-').map(Number)
|
const [y, m] = month.value.split('-').map(Number)
|
||||||
const start = y + '-' + pad(m) + '-01'
|
const start = y + '-' + pad(m) + '-01'
|
||||||
const days = new Date(Date.UTC(y, m, 0)).getUTCDate()
|
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.getAbsences(start, days),
|
||||||
roster.holidays(start, y + '-' + pad(m) + '-' + pad(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
|
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 out = {}
|
||||||
const abs = (absRes && absRes.absences) || {}
|
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)
|
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 }
|
if (mine) for (const d of (mine.days || [])) sh[d.date] = { shift: !d.off, occupancy: d.occupancy }
|
||||||
shiftMap.value = sh
|
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 }
|
} catch (e) { err(e) } finally { calLoading.value = false }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Glisser-sélectionner
|
// Interaction : CLIC SIMPLE (sans glisser) = menu de cellule partagé (Jour/Soir/Garde/Absent/heures) ; GLISSER = pinceau
|
||||||
function startDrag (c) { drag.on = true; drag.sel = new Set([c.iso]) }
|
// rapide de congés (multi-jours, vacances). Le congé reste STAGÉ (publish-required) : on émet le delta au parent, 0 écriture ici.
|
||||||
function overDrag (c) { if (drag.on) drag.sel.add(c.iso) }
|
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 } }
|
||||||
async function endDrag () {
|
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
|
if (!drag.on) return
|
||||||
drag.on = false
|
drag.on = false
|
||||||
const days = [...drag.sel]
|
const days = [...drag.sel]; drag.sel = new Set()
|
||||||
drag.sel = new Set()
|
if (!days.length || !props.tech) return
|
||||||
if (!days.length) return
|
if (!drag.moved) { openCellMenu(days[0]); return } // clic simple → menu (pas d'application du pinceau)
|
||||||
const remove = brush.value === 'clear'
|
const changes = days.map(iso => {
|
||||||
const type = remove ? '' : brush.value
|
if (brush.value === 'clear') return { iso, type: '' }
|
||||||
calLoading.value = true
|
const cur = effAbsType(iso)
|
||||||
let ok = 0
|
return { iso, type: cur === brush.value ? '' : brush.value } // même type → toggle off
|
||||||
try {
|
})
|
||||||
for (const iso of days) {
|
emit('stage-abs', { techId: props.tech.id, changes })
|
||||||
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 } }
|
|
||||||
}
|
}
|
||||||
|
// ── 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) ───────────────────────────────────────────────────
|
// ── Archivage (réversible) ───────────────────────────────────────────────────
|
||||||
const archiveBusy = ref(false)
|
const archiveBusy = ref(false)
|
||||||
|
|
@ -262,9 +304,11 @@ watch(() => props.modelValue, (o) => {
|
||||||
pauseNote.value = ''
|
pauseNote.value = ''
|
||||||
const t = todayISO()
|
const t = todayISO()
|
||||||
month.value = t.slice(0, 7)
|
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()
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<!-- Rangée 1 : actions principales (nav · Outils · Générer · Publier) — DESKTOP/tablette uniquement (gt-sm) -->
|
<!-- 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">
|
<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) -->
|
<!-- 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="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 où la ressource n'a AUCUN quart publié. Repère le ⚠ dans la grille → publier un quart ou réassigner.</q-tooltip></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 où 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 />
|
<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>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-section side><q-icon v-if="showLegacyLoad" name="check" color="brown" /></q-item-section>
|
||||||
</q-item>
|
</q-item>
|
||||||
<q-item clickable v-close-popup @click="showDemand = !showDemand">
|
<q-item clickable v-close-popup @click="boardView = 'month'">
|
||||||
<q-item-section avatar><q-icon name="tune" :color="showDemand ? 'indigo' : 'grey-7'" /></q-item-section>
|
<q-item-section avatar><q-icon name="calendar_month" color="indigo" /></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>Besoins & couverture (vue Mois)<q-item-label caption>heures requises par compétence · alertes de couverture</q-item-label></q-item-section>
|
||||||
<q-item-section side><q-icon v-if="showDemand" name="check" color="indigo" /></q-item-section>
|
|
||||||
</q-item>
|
</q-item>
|
||||||
</q-list>
|
</q-list>
|
||||||
</q-btn-dropdown>
|
</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-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-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>
|
<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 -->
|
<!-- 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-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">
|
<q-list dense style="min-width:250px">
|
||||||
|
|
@ -187,36 +187,7 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Demande -->
|
<!-- « Demande — effectif requis par créneau » RETIRÉ (remplacé par la vue Mois : besoins par compétence + couverture). -->
|
||||||
<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> > 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>
|
|
||||||
|
|
||||||
<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-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" />
|
<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). -->
|
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>
|
<div v-if="selection.length" class="sel-actions" @mousedown.stop>
|
||||||
<span class="text-weight-medium q-mr-xs">{{ selection.length }} cellule(s) :</span>
|
<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="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="brown" icon="shield" label="Garde" @click="bulkGarde" />
|
||||||
<q-btn dense unelevated size="sm" color="negative" icon="event_busy" label="Absent" @click="bulkAbsent" />
|
<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-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_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" />
|
<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 ;
|
<!-- ── 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). ── -->
|
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) -->
|
<!-- 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 v-if="boardView === 'routes'" class="routes-wrap q-pa-sm">
|
||||||
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
|
<div class="row items-center q-mb-sm" style="gap:6px;flex-wrap:wrap">
|
||||||
<span class="text-caption text-grey-7">Journée :</span>
|
<span class="text-caption text-grey-7">Journée :</span>
|
||||||
|
|
@ -668,7 +642,7 @@
|
||||||
<q-dialog v-model="showGarde">
|
<q-dialog v-model="showGarde">
|
||||||
<q-card style="min-width:680px;max-width:760px">
|
<q-card style="min-width:680px;max-width:760px">
|
||||||
<q-card-section class="row items-center q-pb-none">
|
<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-btn flat round dense icon="close" v-close-popup />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-card-section class="q-gutter-y-md">
|
<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="row items-center text-caption text-grey-6 q-pb-xs">
|
||||||
<div class="col">Compétence</div>
|
<div class="col">Compétence</div>
|
||||||
<div style="width:90px" class="text-center">Score</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>
|
||||||
<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 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">
|
<div class="col row items-center no-wrap">
|
||||||
|
|
@ -1424,7 +1398,6 @@
|
||||||
</AssignmentField>
|
</AssignmentField>
|
||||||
</div>
|
</div>
|
||||||
<q-space />
|
<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-btn flat round dense icon="close" v-close-popup />
|
||||||
</q-card-section>
|
</q-card-section>
|
||||||
<q-separator />
|
<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>
|
<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-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">
|
<PlanifCellMenu
|
||||||
<q-item-label header class="q-py-xs">{{ menu.tech && menu.tech.name }} — {{ menu.day && menu.day.dnum }}</q-item-label>
|
:cell-key="(menu.tech && menu.tech.id) + '|' + (menu.day && menu.day.iso)"
|
||||||
<!-- 4 actions : Jour · Soir · Garde · Absent -->
|
:title="(menu.tech && menu.tech.name) + ' — ' + (menu.day && menu.day.dnum)"
|
||||||
<div class="row q-gutter-xs q-px-sm q-pb-xs">
|
:shifts="menuCellShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent"
|
||||||
<q-btn dense unelevated size="sm" color="primary" label="Jour 8–17" class="col" @click="quickShift(8, 17)" />
|
:clipboard-count="cellClipboard.length" :initial-range="menuRange"
|
||||||
<q-btn dense unelevated size="sm" color="deep-purple-5" label="Soir 16–20" class="col" @click="quickShift(16, 20)" />
|
@window="e => applyWindow(e.min, e.max)" @toggle-garde="toggleGardeMenu" @toggle-absent="openAbsDialog"
|
||||||
</div>
|
@remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu" />
|
||||||
<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 (=8→17) -->
|
|
||||||
<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>
|
|
||||||
</q-menu>
|
</q-menu>
|
||||||
|
|
||||||
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
|
<!-- É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" />
|
<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 -->
|
<!-- 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" />
|
<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>
|
</q-page>
|
||||||
</template>
|
</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 { 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 { 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 { 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 { 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 { 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
|
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 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 ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait — décomposition #4)
|
||||||
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
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 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 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)
|
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 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 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 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 $q = useQuasar()
|
||||||
const router = useRouter()
|
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 = {}
|
const vis = new Set(visibleTechs.value.map(t => t.id)); const tpl = tplByName.value; const agg = {}
|
||||||
for (const a of assignments.value) {
|
for (const a of assignments.value) {
|
||||||
if (!vis.has(a.tech)) continue
|
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 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() })
|
const o = agg[a.date] || (agg[a.date] = { hours: 0, staff: new Set() })
|
||||||
o.hours += Number(a.hours) || 0; o.staff.add(a.tech)
|
o.hours += Number(a.hours) || 0; o.staff.add(a.tech)
|
||||||
|
|
@ -2078,17 +2023,16 @@ const unassignedByDay = computed(() => {
|
||||||
})
|
})
|
||||||
const dailyStats = ref([])
|
const dailyStats = ref([])
|
||||||
const solverStats = ref(null)
|
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 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 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 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 drag = reactive({ on: false, ti: 0, di: 0, moved: false, base: [] })
|
||||||
const justDragged = ref(false)
|
const justDragged = ref(false)
|
||||||
const selection = ref([])
|
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 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 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 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 gardeRules = ref([]); const showGarde = ref(false)
|
||||||
const manualGarde = ref({}) // overrides manuels de garde : 'techId|iso' → 'on' | 'off' (touche « G »)
|
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.
|
// 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') }
|
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 »).
|
// 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') }
|
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.
|
// ── 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). ──
|
// 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 { 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(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é
|
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)
|
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. ──
|
// ── 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: '' })
|
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'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
|
// Décode les entités HTML (« d'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
|
||||||
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
|
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 }
|
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)) }
|
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
|
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 }
|
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)
|
pushHistory()
|
||||||
await reloadAbsences(); await reloadOccupancy()
|
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 retour = addDaysISO(days[days.length - 1], 1)
|
||||||
const noms = dropAsk.names.slice(); dropAsk.open = false
|
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}) · retour le ${retour}. ${noms.length} job(s) laissé(s) au pool.`, timeout: 8000, actions: [{ label: 'Assigner à un autre tech', color: 'white', handler: () => openAssignPanel() }] })
|
$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() }] })
|
||||||
await checkAbsenceImpact(days.map(dd => t.id + '|' + dd)) // redistribue les jobs DÉJÀ assignés sur ces jours (dialogue existant)
|
|
||||||
}
|
}
|
||||||
// ── Absence sur PLAGE (comme l'ancien dispatch) : défaut = jour affiché ; options « ce jour / cette semaine / plage du..au ». ──
|
// ── 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: '' })
|
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 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 }
|
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
|
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)
|
pushHistory(); let n = 0
|
||||||
await reloadAbsences(); await reloadOccupancy()
|
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 (' + days.length + ' j)') : (days.length + ' jour(s) marqué(s) absent') })
|
$q.notify({ type: 'info', message: (remove ? ('Absence retirée (' + n + ' j)') : (n + ' jour(s) marqué(s) absent')) + ' — Publier pour appliquer' })
|
||||||
absDialog.open = false
|
absDialog.open = false; scheduleDraftSave()
|
||||||
if (!remove) await checkAbsenceImpact(days.map(d => absDialog.techId + '|' + d))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mini-carte du volet détails : zoome sur le job + layer « tracé GPS réel » (Traccar) en option ──
|
// ── 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(() => 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 } })
|
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 techOptions = computed(() => techs.value.map(t => ({ label: t.name, value: t.id })))
|
||||||
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
|
const tplByName = computed(() => Object.fromEntries(templates.value.map(t => [t.name, t])))
|
||||||
// ── CALQUE de garde LIVE ──────────────────────────────────────────────────
|
// ── 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. ──
|
// ── É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 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 }
|
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
|
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) {
|
async function onTechSchedChanged (ev) {
|
||||||
// pause : reflète le statut localement ; archivage : recharge la base (le tech disparaît). Congés : recharge la semaine (hachures).
|
// 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 }
|
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,
|
// 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.
|
// 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 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
|
// 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).
|
// (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).
|
// 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)
|
if (sk) return skillSym(sk)
|
||||||
return (b && b.legacy) ? 'confirmation_number' : skillSym('')
|
return (b && b.legacy) ? 'confirmation_number' : skillSym('')
|
||||||
}
|
}
|
||||||
// Camion-nacelle (bucket truck) pour « monteur » — Material Symbols n'a pas d'équivalent → SVG custom au format
|
// (skillIcon / skillSym / markerIcon / BUCKET_TRUCK → composables/useSkillIcons.js — SOURCE UNIQUE réutilisable)
|
||||||
// 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'
|
|
||||||
}
|
|
||||||
function onTagsChange (t, items) {
|
function onTagsChange (t, items) {
|
||||||
const newLabels = (items || []).map(x => typeof x === 'string' ? x : x.tag).filter(Boolean)
|
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
|
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 })
|
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.
|
// #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 unpublished = computed(() => assignments.value.filter(a => a.status && a.status !== 'Publié'))
|
||||||
const dirty = computed(() => unpublished.value.length > 0)
|
// « dirty » = quarts NON PUBLIÉS + congés en attente (publish-required) → tout doit passer par « Publier ».
|
||||||
const dirtyCount = computed(() => unpublished.value.length)
|
const dirty = computed(() => unpublished.value.length > 0 || pendingAbsCount.value > 0)
|
||||||
const dirtyCells = computed(() => new Set(unpublished.value.map(a => a.tech + '|' + a.date)))
|
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) }
|
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.
|
// 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é' })
|
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
|
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é
|
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é
|
const absByTechDay = ref({}) // tech|date → type d'absence SERVEUR (En pause / Congé / Maladie…) → hachuré
|
||||||
function isAbsent (techId, iso) { return !!absByTechDay.value[techId + '|' + iso] }
|
// #congé PUBLISH-REQUIRED : couche LOCALE d'absences en attente (delta vs serveur), écrite au serveur SEULEMENT à « Publier ».
|
||||||
function absenceLabel (techId, iso) { return absByTechDay.value[techId + '|' + iso] || 'Absent' }
|
// 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) {} }
|
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.
|
// Pose le delta d'absence d'une cellule (wantType=null → non absent). N'écrit PAS au serveur (publish-required).
|
||||||
async function toggleAbsentCells (targets) {
|
// 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
|
if (!targets || !targets.length) return
|
||||||
const allAbsent = targets.every(k => { const [tid, iso] = k.split('|'); return isAbsent(tid, iso) })
|
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) } }
|
const want = !allAbsent
|
||||||
await reloadAbsences()
|
pushHistory(); let n = 0
|
||||||
$q.notify({ type: 'info', message: allAbsent ? 'Absence retirée' : (targets.length + ' absence(s) marquée(s)') })
|
for (const k of targets) {
|
||||||
if (!allAbsent) await checkAbsenceImpact(targets) // marquage → vérifier les jobs assignés impactés (IROPS)
|
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)) }
|
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.
|
// 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é ;
|
// 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=▲).
|
// 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é.
|
// 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 h = +(o && o.usedH) || 0
|
||||||
const book = +(o && o.bookableH) || 0
|
const book = +(o && o.bookableH) || 0
|
||||||
const cap = (hasShift && book > 0) ? book : 8
|
const absent = (techId && iso) ? isAbsent(techId, iso) : false
|
||||||
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 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.
|
// Barre globale du jour = Σ durées jobs estimées (numérateur) / Σ durées des quarts (dénominateur) = couverture du jour.
|
||||||
const mobileDayLoads = computed(() => {
|
const mobileDayLoads = computed(() => {
|
||||||
|
|
@ -4431,9 +4398,10 @@ const mobileDayLoads = computed(() => {
|
||||||
for (const t of ts) {
|
for (const t of ts) {
|
||||||
const o = techDayOcc(t.id, d.iso); if (!o) continue
|
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 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)
|
const absent = isAbsent(t.id, d.iso); const works = patternWorksDay(t.id, d.iso)
|
||||||
assignedH += u; capH += cap; if (u > cap + 0.01) over++
|
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
|
||||||
if (!hasSh && u > 0.01) noShift++ // tech avec du travail mais aucun quart planifié
|
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.
|
// 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
|
let pendingH = 0
|
||||||
|
|
@ -4445,7 +4413,7 @@ const mobileDayLoads = computed(() => {
|
||||||
})
|
})
|
||||||
const mobileTechLoads = computed(() => {
|
const mobileTechLoads = computed(() => {
|
||||||
const iso = mobileSelDay.value
|
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 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))
|
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))
|
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.
|
// 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.
|
// 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 ──
|
// ── 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 jlat = +(job && job.lat); const jlon = +(job && job.lon)
|
||||||
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
|
const hasJC = isFinite(jlat) && isFinite(jlon) && (jlat || jlon)
|
||||||
return (visibleTechs.value || []).map(t => {
|
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 capable = !reqSkillList.length || reqSkillList.every(s => (t.skills || []).includes(s))
|
||||||
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
const home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||||||
const distKm = (hasJC && home) ? haversineKm(home.lat, home.lon, jlat, jlon) : null
|
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 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.)
|
const iso = jobTargetDay(jobs[0]) // jour de référence pour la charge (approx.)
|
||||||
return (visibleTechs.value || []).map(t => {
|
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 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 home = techOrigin(t.id) // domicile, sinon bureau TARGO (défaut)
|
||||||
const distKm = (cLat != null && home) ? haversineKm(home.lat, home.lon, cLat, cLon) : null
|
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 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 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 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)
|
// 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'
|
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.
|
// 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
|
const placeholder = isPh
|
||||||
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
|
let worstPct = 0, worstProj = 0, worstCap = 8, existing = 0, over = false
|
||||||
if (!placeholder) for (const d of days) {
|
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 dcap = l.cap || 8; const dproj = (l.h || 0) + (d.hours || 0); existing += (l.h || 0)
|
||||||
const pct = dcap ? dproj / dcap * 100 : 0
|
const pct = dcap ? dproj / dcap * 100 : 0
|
||||||
if (pct > worstPct) { worstPct = pct; worstProj = dproj; worstCap = dcap }
|
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 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' } }
|
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
|
// undo / redo — capture les quarts ET les congés en attente (les 2 sont annulables via Ctrl+Z).
|
||||||
function snap () { return JSON.parse(JSON.stringify(assignments.value)) }
|
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 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 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()); assignments.value = future.value.pop(); logChange('↷ Rétabli'); scheduleDraftSave() }
|
function redo () { if (!future.value.length) return; history.value.push(snap()); restoreSnap(future.value.pop()); logChange('↷ Rétabli'); scheduleDraftSave() }
|
||||||
|
|
||||||
// garde anti-perte
|
// 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).
|
// #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.
|
// #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 }
|
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 () {
|
async function doPublishConfirmed () {
|
||||||
publishing.value = true
|
publishing.value = true
|
||||||
try {
|
try {
|
||||||
// Mode 'publish' : promeut les brouillons (Proposé/Soumis/Approuvé) → Publié + SMS. Les éditions étaient déjà auto-sauvées.
|
// 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 r = await roster.publishWeek(start.value, days.value, assignments.value, notifySms.value, 'publish')
|
||||||
const done = (r.created || 0) + (r.promoted || 0)
|
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
|
pubConfirm.value = false
|
||||||
await loadWeek()
|
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 }
|
} 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).
|
// #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
|
// 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) ────────────────
|
// ── 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
|
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 })
|
$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) }
|
} catch (e) { err(e) }
|
||||||
}
|
}
|
||||||
function saveDemand () { localStorage.setItem(LS_DEMAND, JSON.stringify(demand.value)) }
|
// « Demande » (besoins par template) RETIRÉ → remplacé par la vue Mois (MonthOverview) : besoins EN HEURES par compétence,
|
||||||
function onDemandSkills (d, items) { d.skills = normSkillList(items); saveDemand() } // TagEditor émet un tableau ; d.skills reste un tableau (→ CSV à la génération)
|
// couverture + alertes, et génération des Shift Requirements pour le solveur. Voir components/planif/MonthOverview.vue.
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// modèles de semaine
|
// modèles de semaine
|
||||||
function saveTemplate () {
|
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).
|
// 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) {
|
function revertChange (c) {
|
||||||
if (!c || !c.revert || c.reverted) return
|
if (!c || !c.revert || c.reverted) return
|
||||||
const { tech, date, before } = c.revert
|
|
||||||
pushHistory()
|
pushHistory()
|
||||||
const others = assignments.value.filter(a => !(a.tech === tech && a.date === date))
|
if (c.revert.absKey) { // congé : restaure le delta d'absence précédent de la cellule
|
||||||
assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))]
|
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
|
c.reverted = true
|
||||||
logChange('↶ Annulé · ' + c.text)
|
logChange('↶ Annulé · ' + c.text)
|
||||||
$q.notify({ type: 'info', message: 'Changement annulé', timeout: 1600 })
|
$q.notify({ type: 'info', message: 'Changement annulé', timeout: 1600 })
|
||||||
|
|
@ -6076,17 +6045,15 @@ async function applyWindow (min, max) {
|
||||||
const tpl = await ensureWindowTpl(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 }
|
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 » (=8→17, dernier chiffre en pm si ≤ début).
|
// Saisie rapide d'heures : « 8-17 » · « 8:30-16 » · « 830-16 » · « 85 » (=8→17, 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 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) {
|
function parseQuickShift (str) {
|
||||||
const s = (str || '').trim().toLowerCase(); if (!s) return null
|
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 (/[-–—]|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 »=8–16 · « 1016 »=10–16
|
||||||
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 » = 8→17
|
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 » = 8→17
|
||||||
return null
|
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 = [] }
|
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 ──
|
// ── 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) }
|
async function bulkWindow (min, max) { if (!selection.value.length) return; const tpl = await ensureWindowTpl(min, max); if (tpl) assignBulk(tpl) }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user