Planification: copier-coller de cases + créneaux custom (slider q-range) + auto-nommage

- 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) <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-06-04 16:02:15 -04:00
parent dfefd7822f
commit c2f3e4d666

View File

@ -82,6 +82,9 @@
<q-banner v-if="selection.length" dense rounded class="bg-teal-1 text-teal-9 q-mb-sm">
{{ selection.length }} cellule(s) assigner :
<q-btn v-for="t in templatesRanked" :key="t.name" dense unelevated size="sm" class="q-mx-xs" :style="chip(t.color)" :label="code(t)" @click="assignBulk(t)"><q-tooltip>{{ t.template_name }}</q-tooltip></q-btn>
<q-separator vertical class="q-mx-xs" />
<q-btn dense flat size="sm" icon="content_copy" label="Copier" @click="copyCell" />
<q-btn dense flat size="sm" icon="content_paste" :label="cellClipboard.length ? ('Coller (' + cellClipboard.length + ')') : 'Coller'" :disable="!cellClipboard.length" @click="pasteCells" />
<q-btn dense flat size="sm" icon="layers_clear" label="Libérer" @click="clearBulk" />
<q-btn dense flat size="sm" icon="close" label="Annuler" @click="selection = []" />
</q-banner>
@ -92,7 +95,7 @@
<span class="code-chip" style="background:#e0e0e0;color:#777">P</span><span class="text-grey-7 q-mr-sm">pause</span>
<span class="free q-mr-xs">·</span><span class="text-grey-7 q-mr-sm">libre</span>
<span class="cell-dirty-demo q-mr-xs">J</span><span class="text-grey-7 q-mr-sm">modifié (non publié)</span>
<span class="text-teal-8">· <b>glisser</b> = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1</span>
<span class="text-teal-8">· <b>glisser</b> = sélection · shift+clic = bloc · clic en-tête = colonne · clic nom = rangée · ctrl+clic = +1 · <b>ctrl+C/V</b> = copier/coller une case</span>
</div>
<div class="grid-wrap">
@ -176,10 +179,11 @@
</table>
<q-separator class="q-my-md" />
<div class="row items-center q-gutter-sm">
<q-input dense outlined v-model="newTpl.template_name" label="Nouveau type" style="width:170px" />
<q-input dense outlined type="time" v-model="newTpl.start" label="Début" style="width:110px" />
<q-input dense outlined type="time" v-model="newTpl.end" label="Fin" style="width:110px" />
<span class="text-caption text-grey-7">{{ calcHours(newTpl.start, newTpl.end) }} h</span>
<q-input dense outlined v-model="newTpl.template_name" label="Nom (auto si vide)" style="width:150px" />
<div style="width:230px;padding:0 8px">
<q-range v-model="newTplRange" :min="0" :max="24" :step="0.5" snap label :left-label-value="fmtH(newTplRange.min) + 'h'" :right-label-value="fmtH(newTplRange.max) + 'h'" color="primary" />
</div>
<span class="text-caption text-grey-7 text-weight-medium">{{ fmtH(newTplRange.min) }}h{{ fmtH(newTplRange.max) }}h · {{ calcHours(newTpl.start, newTpl.end) }} h</span>
<input type="color" v-model="newTpl.color" style="width:36px;height:26px;border:none;background:none" />
<q-toggle dense v-model="newTpl.on_call" :true-value="1" :false-value="0" label="🛡️ Garde" color="brown" />
<q-btn dense unelevated color="primary" icon="add" label="Ajouter" @click="addShiftTpl" />
@ -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) })