diff --git a/apps/ops/src/components/planif/MonthOverview.vue b/apps/ops/src/components/planif/MonthOverview.vue
new file mode 100644
index 0000000..3964b62
--- /dev/null
+++ b/apps/ops/src/components/planif/MonthOverview.vue
@@ -0,0 +1,252 @@
+
+
+
+
+
+
{{ monthLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
Besoins — heures requises par compétence
+
+
+ Traduit ces heures requises en besoins de couverture (Shift Requirements) pour le solveur « Générer », sur tout le mois affiché. Remplace les besoins existants du mois.
+
+
+
+ Heures/jour requises par compétence. La couverture = heures des techs en quart (hors garde, hors absents) qui ont la compétence. Case rouge = déficit.
+
+
+
+
+
+
{{ d }}
+
+
+ {{ c.day }}★
+
+
+ {{ t.initials }}
+ +{{ c.onShift.length - 5 }}
+
+
+
+
+ {{ df.skill }} −{{ df.deficit }}h
+
+
+ {{ c.iso }} · férié
+ {{ c.onShift.length }} en quart : {{ c.onShift.map(t => t.name + ' (' + t.hours + 'h)').join(', ') }}
+ {{ c.absent.length }} absent(s) : {{ c.absent.map(t => t.name + ' — ' + t.type).join(', ') }}
+ Couverture :
• {{ cov.skill }} : {{ cov.avail }}/{{ cov.req }}h (manque {{ cov.deficit }}h)
+
+
+
+
+
+ en quart
+ couverture insuffisante
+ férié
+
+ {{ monthTotals.onDays }} jour(s) couvert(s) · {{ monthTotals.shortDays }} en déficit
+
+
+
+
+
+
+
diff --git a/apps/ops/src/components/planif/PlanifCellMenu.vue b/apps/ops/src/components/planif/PlanifCellMenu.vue
new file mode 100644
index 0000000..e0158d1
--- /dev/null
+++ b/apps/ops/src/components/planif/PlanifCellMenu.vue
@@ -0,0 +1,79 @@
+
+
+
+ {{ title }}
+
+
+
+
+
+
+ Mettre / retirer de garde (G) — en parallèle d'un shift
+ Congé / absence — à publier
+
+
+
+
+ Appliquer
+
+
+
+
+
+
+
+ {{ fmtH(range.min) }}h–{{ fmtH(range.max) }}h
+
+
+
+
+
+
+
+
+ {{ a.shift_name || a.shift }} {{ a.hours }}h
+ Retirer
+
+
+ Copier la case
+ Coller{{ clipboardCount ? ' (' + clipboardCount + ')' : '' }}
+
+
+
+
+
+
+
diff --git a/apps/ops/src/components/planif/TechScheduleDialog.vue b/apps/ops/src/components/planif/TechScheduleDialog.vue
index 8f55adf..6630536 100644
--- a/apps/ops/src/components/planif/TechScheduleDialog.vue
+++ b/apps/ops/src/components/planif/TechScheduleDialog.vue
@@ -53,14 +53,15 @@
{{ b.label }}
- glisse sur les jours pour appliquer
+ glisse pour marquer · re-clique pour annuler · à publier
-
-
+
+
{{ d }}
{{ c.day }}
@@ -74,6 +75,16 @@
+
+
+
+
+
{{ b.label }}
aujourd'hui
@@ -100,12 +111,16 @@ import { ref, reactive, computed, watch } from 'vue'
import { useQuasar } from 'quasar'
import * as roster from 'src/api/roster'
import { techOccupancy } from 'src/api/dispatch' // occupation/quart par jour du tech → grise les jours sans quart, barre d'occupation les jours avec quart
+import PlanifCellMenu from 'src/components/planif/PlanifCellMenu.vue' // menu de cellule PARTAGÉ (identique à la grille semaine/jour)
const props = defineProps({
modelValue: { type: Boolean, default: false },
tech: { type: Object, default: null }, // { id, name, status, ... }
+ pendingAbs: { type: Object, default: () => ({}) }, // congés EN ATTENTE (delta local du parent, publish-required) → superposés à l'état serveur
+ gardeMap: { type: Object, default: () => ({}) }, // garde EFFECTIVE du parent (gardeEffective) : clé tech|iso → shift
+ refreshKey: { type: Number, default: 0 }, // ↑ par le parent après une écriture de quart → recharge le mois
})
-const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed'])
+const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed', 'stage-abs', 'set-shift', 'remove-shift', 'clear-shifts', 'toggle-garde'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
@@ -145,10 +160,24 @@ const month = ref('') // 'YYYY-MM'
const calLoading = ref(false)
const absMap = ref({}) // 'YYYY-MM-DD' → type d'absence
const shiftMap = ref({}) // 'YYYY-MM-DD' → { shift:bool, occupancy:0..1 } (quart réel du tech ce jour) → gris = pas de quart
+const asgByDate = ref({}) // 'YYYY-MM-DD' → [{ name, shift, shift_name, hours }] (quarts réguliers du tech ce jour) → menu « shifts en place »
const holiSet = ref(new Set())
-const drag = reactive({ on: false, sel: new Set() })
+const drag = reactive({ on: false, sel: new Set(), moved: false })
const selCount = computed(() => drag.sel.size)
+// Menu de cellule (clic simple) — ancré au curseur (comme la grille).
+const cellAnchor = ref(null)
+const cellMenu = reactive({ show: false, iso: '', x: 0, y: 0 })
+const menuShifts = computed(() => asgByDate.value[cellMenu.iso] || [])
+const menuIsGarde = computed(() => !!(props.tech && props.gardeMap && props.gardeMap[props.tech.id + '|' + cellMenu.iso]))
+const menuIsAbsent = computed(() => !!effAbsType(cellMenu.iso))
+function fmtDayLabel (iso) { if (!iso) return ''; return iso.slice(8) + '/' + iso.slice(5, 7) }
+// Type d'absence EFFECTIF pour un jour = serveur (absMap) écrasé par le delta EN ATTENTE du parent. null = pas absent.
+function effAbsType (iso) {
+ const p = props.pendingAbs && props.tech && props.pendingAbs[props.tech.id + '|' + iso]
+ if (p) return p.op === 'set' ? (p.type || 'Congé') : null
+ return absMap.value[iso] || null
+}
const MO = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre']
const monthLabel = computed(() => { const [y, m] = month.value.split('-').map(Number); return MO[m - 1] + ' ' + y })
function todayISO () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
@@ -167,7 +196,7 @@ const monthCells = computed(() => {
const iso = y + '-' + pad(m) + '-' + pad(day)
const dow = new Date(iso + 'T12:00:00').getUTCDay()
const sh = shiftMap.value[iso]
- cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: absMap.value[iso] || null, holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null })
+ cells.push({ inMonth: true, day, iso, dow, weekend: dow === 0 || dow === 6, isToday: iso === t, absType: effAbsType(iso), holiday: holiSet.value.has(iso), sel: drag.sel.has(iso), hasShift: !!(sh && sh.shift), occupancy: sh ? sh.occupancy : null })
}
while (cells.length % 7) cells.push({ inMonth: false })
return cells
@@ -195,10 +224,11 @@ async function loadCal () {
const [y, m] = month.value.split('-').map(Number)
const start = y + '-' + pad(m) + '-01'
const days = new Date(Date.UTC(y, m, 0)).getUTCDate()
- const [absRes, holiRes, occRes] = await Promise.all([
+ const [absRes, holiRes, occRes, asgRes] = await Promise.all([
roster.getAbsences(start, days),
roster.holidays(start, y + '-' + pad(m) + '-' + pad(days)),
techOccupancy({ after_date: start, days, skill: '' }).catch(() => null), // quart + occupation par jour (tous techs) → on garde le nôtre
+ roster.listAssignments(start, days).catch(() => null), // quarts réguliers du mois → menu « shifts en place » / retrait
])
const out = {}
const abs = (absRes && absRes.absences) || {}
@@ -213,29 +243,41 @@ async function loadCal () {
const mine = occRes && (occRes.techs || []).find(x => x.tech_id === props.tech.id || x.tech_name === props.tech.name)
if (mine) for (const d of (mine.days || [])) sh[d.date] = { shift: !d.off, occupancy: d.occupancy }
shiftMap.value = sh
+ // Quarts réguliers du mois pour CE tech → menu « shifts en place » + retrait par docname.
+ const abd = {}
+ for (const a of ((asgRes && asgRes.assignments) || [])) {
+ if (a.tech !== props.tech.id && a.tech !== props.tech.name) continue
+ ;(abd[a.date] || (abd[a.date] = [])).push({ name: a.name, shift: a.shift, shift_name: a.shift_name || a.shift, hours: a.hours })
+ }
+ asgByDate.value = abd
} catch (e) { err(e) } finally { calLoading.value = false }
}
-// Glisser-sélectionner
-function startDrag (c) { drag.on = true; drag.sel = new Set([c.iso]) }
-function overDrag (c) { if (drag.on) drag.sel.add(c.iso) }
-async function endDrag () {
+// Interaction : CLIC SIMPLE (sans glisser) = menu de cellule partagé (Jour/Soir/Garde/Absent/heures) ; GLISSER = pinceau
+// rapide de congés (multi-jours, vacances). Le congé reste STAGÉ (publish-required) : on émet le delta au parent, 0 écriture ici.
+function startDrag (c, ev) { drag.on = true; drag.moved = false; drag.sel = new Set([c.iso]); if (ev) { cellMenu.x = ev.clientX; cellMenu.y = ev.clientY } }
+function overDrag (c) { if (!drag.on) return; if (!drag.sel.has(c.iso)) drag.moved = true; drag.sel.add(c.iso) }
+function cancelDrag () { drag.on = false; drag.sel = new Set(); drag.moved = false }
+function endDrag () {
if (!drag.on) return
drag.on = false
- const days = [...drag.sel]
- drag.sel = new Set()
- if (!days.length) return
- const remove = brush.value === 'clear'
- const type = remove ? '' : brush.value
- calLoading.value = true
- let ok = 0
- try {
- for (const iso of days) {
- try { await roster.setAbsence(props.tech.id, iso, type, remove); ok++; if (remove) delete absMap.value[iso]; else absMap.value[iso] = type } catch (e) { err(e) }
- }
- if (ok) { $q.notify({ type: 'positive', message: (remove ? 'Effacé' : 'Marqué « ' + type + ' »') + ' — ' + ok + ' jour(s)', timeout: 1800 }); emit('changed', { id: props.tech.id }) }
- } finally { calLoading.value = false; absMap.value = { ...absMap.value } }
+ const days = [...drag.sel]; drag.sel = new Set()
+ if (!days.length || !props.tech) return
+ if (!drag.moved) { openCellMenu(days[0]); return } // clic simple → menu (pas d'application du pinceau)
+ const changes = days.map(iso => {
+ if (brush.value === 'clear') return { iso, type: '' }
+ const cur = effAbsType(iso)
+ return { iso, type: cur === brush.value ? '' : brush.value } // même type → toggle off
+ })
+ emit('stage-abs', { techId: props.tech.id, changes })
}
+// ── Menu de cellule (clic simple) — émet des intentions au parent (single source of logic). ──
+function openCellMenu (iso) { cellMenu.iso = iso; cellMenu.show = true }
+function onMenuWindow (r) { if (props.tech && r) emit('set-shift', { techId: props.tech.id, iso: cellMenu.iso, min: r.min, max: r.max }); cellMenu.show = false }
+function onMenuGarde () { if (props.tech) emit('toggle-garde', { techId: props.tech.id, iso: cellMenu.iso }); cellMenu.show = false }
+function onMenuAbsent () { if (props.tech) emit('stage-abs', { techId: props.tech.id, changes: [{ iso: cellMenu.iso, type: menuIsAbsent.value ? '' : 'Congé' }] }); cellMenu.show = false }
+function onMenuRemoveShift (a) { if (a && a.name) emit('remove-shift', { name: a.name }); cellMenu.show = false }
+function onMenuClear () { if (props.tech) emit('clear-shifts', { techId: props.tech.id, iso: cellMenu.iso, names: (asgByDate.value[cellMenu.iso] || []).map(a => a.name) }); cellMenu.show = false }
// ── Archivage (réversible) ───────────────────────────────────────────────────
const archiveBusy = ref(false)
@@ -262,9 +304,11 @@ watch(() => props.modelValue, (o) => {
pauseNote.value = ''
const t = todayISO()
month.value = t.slice(0, 7)
- absMap.value = {}; shiftMap.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set()
+ absMap.value = {}; shiftMap.value = {}; asgByDate.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set(); cellMenu.show = false
loadCal()
})
+// Le parent a persisté un quart (createShift/deleteAssignment) → recharger le mois pour refléter les shifts.
+watch(() => props.refreshKey, () => { if (props.modelValue) loadCal() })