From c2f3e4d666ca3359551104594efca88c07a71e49 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Thu, 4 Jun 2026 16:02:15 -0400 Subject: [PATCH] =?UTF-8?q?Planification:=20copier-coller=20de=20cases=20+?= =?UTF-8?q?=20cr=C3=A9neaux=20custom=20(slider=20q-range)=20+=20auto-nomma?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Copier-coller pour bâtir l'horaire vite : sélectionne une case → Copier (ses shifts) → sélectionne des cibles → Coller (duplique). Boutons dans la barre + raccourcis Ctrl+C / Ctrl+V. Copier une case vide puis Coller = vider les cases. - Créneaux CUSTOM : nouveau modèle créé via slider q-range (2 poignées, pas 0.5 h) → plus besoin de prévoir tous les types. Nom AUTO si vide (« 8h–17h » d'après les heures). - Presets standards semés : 7h–15h, 9h–17h, 8h–17h (+ Jour 8h-16h existant) — triés par usage. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/ops/src/pages/PlanificationPage.vue | 30 ++++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 0388aa3..9557009 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -82,6 +82,9 @@ {{ selection.length }} cellule(s) — assigner : {{ t.template_name }} + + + @@ -92,7 +95,7 @@ Ppause ·libre Jmodifié (non publié) - · glisser = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1 + · glisser = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1 · ctrl+C/V = copier/coller une case
@@ -176,10 +179,11 @@
- - - - {{ calcHours(newTpl.start, newTpl.end) }} h + +
+ +
+ {{ fmtH(newTplRange.min) }}h–{{ fmtH(newTplRange.max) }}h · {{ calcHours(newTpl.start, newTpl.end) }} h @@ -300,6 +304,9 @@ const notifySms = ref(false) const showLeave = ref(false); const leaveRows = ref([]); const leaveFilter = ref('Demandé') const newLeave = reactive({ technician: '', availability_type: 'Congé', from_date: '', to_date: '', reason: '' }) const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 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') } +// Slider à 2 poignées pour le nouveau modèle (heures custom) ↔ newTpl.start/end +const newTplRange = computed({ get: () => ({ min: hToNum(newTpl.start) || 8, max: hToNum(newTpl.end) || 16 }), set: (v) => { newTpl.start = numToTime(v.min); newTpl.end = numToTime(v.max) } }) const LS_DEMAND = 'roster-demand-v1'; const LS_HOL = 'roster-holidays-v1'; const LS_TPL = 'roster-week-templates-v1' @@ -459,7 +466,7 @@ async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.s function calcHours (st, et) { if (!st || !et) return 0; const [h1, m1] = st.split(':').map(Number); const [h2, m2] = et.split(':').map(Number); let mins = (h2 * 60 + m2) - (h1 * 60 + m1); if (mins < 0) mins += 1440; return Math.round(mins / 60 * 100) / 100 } function openShiftEditor () { editTpls.value = templates.value.map(t => ({ name: t.name, template_name: t.template_name, start: (t.start_time || '08:00:00').slice(0, 5), end: (t.end_time || '16:00:00').slice(0, 5), color: t.color || '#1976d2', on_call: t.on_call ? 1 : 0 })); showShiftEditor.value = true } async function saveShiftTpl (t) { try { await roster.updateTemplate(t.name, { start_time: t.start + ':00', end_time: t.end + ':00', hours: calcHours(t.start, t.end), color: t.color, on_call: t.on_call ? 1 : 0 }); await refreshTemplates(); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } catch (e) { err(e) } } -async function addShiftTpl () { if (!newTpl.template_name) { $q.notify({ type: 'warning', message: 'Nom requis' }); return } try { await roster.createTemplate({ template_name: newTpl.template_name, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1, on_call: newTpl.on_call ? 1 : 0 }); newTpl.template_name = ''; newTpl.on_call = 0; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type ajouté' }) } catch (e) { err(e) } } +async function addShiftTpl () { const nm = (newTpl.template_name || '').trim() || (fmtH(hToNum(newTpl.start)) + 'h–' + fmtH(hToNum(newTpl.end)) + 'h'); try { await roster.createTemplate({ template_name: nm, start_time: newTpl.start + ':00', end_time: newTpl.end + ':00', hours: calcHours(newTpl.start, newTpl.end), color: newTpl.color, default_required: 1, on_call: newTpl.on_call ? 1 : 0 }); newTpl.template_name = ''; newTpl.on_call = 0; await refreshTemplates(); openShiftEditor(); $q.notify({ type: 'positive', message: 'Type « ' + nm + ' » ajouté' }) } catch (e) { err(e) } } async function delShiftTpl (t) { if (!window.confirm('Supprimer le type « ' + t.template_name + ' » ?')) return; try { await roster.deleteShiftTemplate(t.name); await refreshTemplates(); editTpls.value = editTpls.value.filter(x => x.name !== t.name); $q.notify({ type: 'info', message: 'Type supprimé' }) } catch (e) { err(e) } } function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) } async function loadWeek () { @@ -570,11 +577,20 @@ function removeShiftFromMenu (a) { pushHistory(); removeShift(a.tech, a.date, a. function clearOne () { if (menu.tech && menu.day) { pushHistory(); clearLocal(menu.tech.id, menu.day.iso); menu.show = false } } 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 clearBulk () { pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); clearLocal(tid, iso) } selection.value = [] } +// Copier-coller une case (bâtir l'horaire vite) : copie les shifts de la 1re case sélectionnée → colle dans les autres +const cellClipboard = ref([]) +function copyCell () { const k = selection.value[0]; if (!k) { $q.notify({ type: 'warning', message: 'Sélectionne une case à copier' }); return } const [tid, iso] = k.split('|'); cellClipboard.value = cellsOf(tid, iso).map(a => a.shift); $q.notify({ type: 'positive', message: cellClipboard.value.length ? (cellClipboard.value.length + ' shift(s) copié(s) — sélectionne des cases puis Coller') : 'Case vide copiée (Coller videra les cases)' }) } +function pasteCells () { if (!selection.value.length) { $q.notify({ type: 'warning', message: 'Sélectionne les cases cibles' }); return } pushHistory(); for (const k of selection.value) { const [tid, iso] = k.split('|'); const t = techs.value.find(x => x.id === tid); if (!cellClipboard.value.length) { clearLocal(tid, iso); continue } for (const name of cellClipboard.value) { const tpl = tplByName.value[name]; if (tpl) addShift(tid, t ? t.name : tid, iso, tpl) } } selection.value = [] } async function togglePause (t) { try { const paused = !isPaused(t); await roster.pauseTechnician(t.id, paused); t.status = paused ? 'En pause' : 'Disponible'; $q.notify({ type: 'info', message: t.name + (paused ? ' en pause' : ' réactivé') }) } catch (e) { err(e) } } function err (e) { $q.notify({ type: 'negative', message: '' + (e.message || e) }) } -function onKey (e) { const z = e.key.toLowerCase() === 'z'; if ((e.ctrlKey || e.metaKey) && z) { e.preventDefault(); if (e.shiftKey) redo(); else undo() } } +function onKey (e) { + const k = e.key.toLowerCase() + if ((e.ctrlKey || e.metaKey) && k === 'z') { e.preventDefault(); if (e.shiftKey) redo(); else undo(); return } + if ((e.ctrlKey || e.metaKey) && k === 'c' && selection.value.length) { e.preventDefault(); copyCell(); return } + if ((e.ctrlKey || e.metaKey) && k === 'v' && selection.value.length) { e.preventDefault(); pasteCells() } +} function onUnload (e) { if (dirty.value) { e.preventDefault(); e.returnValue = '' } } onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } await loadWeek() }) onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload) })