planif : extraction #4/3 — dialogue Types de shift → components/planif/ShiftTypesDialog.vue
3e extraction. Couplage traité proprement : le dialogue édite les Shift Template et la liste `templates` est PARTAGÉE (grille/garde/demande) → le composant émet `changed` et le parent recharge via refreshTemplates. - components/planif/ShiftTypesDialog.vue (NOUVEAU) : editTpls + newTpl + newTplRange + save/add/del déplacés ; semé depuis la prop `templates` à l'ouverture (watch) ; après add → refetch local ; helpers copiés (calcHours/fmtH/hToNum/numToTime/chip) ; CSS .demand-tbl + .code-chip recopiés - PlanificationPage : dialogue + état + 4 fonctions retirés ; `chip()` supprimé (seul consommateur déplacé) ; helpers partagés (calcHours/fmtH/hToNum/numToTime) CONSERVÉS ; bouton « Types de shift » → `showShiftEditor = true` ; `<ShiftTypesDialog @changed="refreshTemplates">` - Vérifié en aperçu : 20 modèles rendus (Jour/Soir/Matinal…), pastille couleur stylée, 0 erreur Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4bed6bb9fd
commit
2396c10f14
86
apps/ops/src/components/planif/ShiftTypesDialog.vue
Normal file
86
apps/ops/src/components/planif/ShiftTypesDialog.vue
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<template>
|
||||
<q-dialog v-model="open">
|
||||
<q-card style="min-width:580px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-subtitle1 text-weight-bold">Types de shift</div><q-space />
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<table class="demand-tbl">
|
||||
<thead><tr><th>Nom</th><th>Début</th><th>Fin</th><th>Heures</th><th>Couleur</th><th>🛡️ Garde</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="t in editTpls" :key="t.name">
|
||||
<td><span class="code-chip" :style="chip(t.color)">{{ (t.template_name||'?')[0].toUpperCase() }}</span> {{ t.template_name }}</td>
|
||||
<td><q-input dense outlined type="time" v-model="t.start" style="width:115px" /></td>
|
||||
<td><q-input dense outlined type="time" v-model="t.end" style="width:115px" /></td>
|
||||
<td class="text-center text-weight-medium">{{ calcHours(t.start, t.end) }} h</td>
|
||||
<td><input type="color" v-model="t.color" style="width:36px;height:26px;border:none;background:none" /></td>
|
||||
<td class="text-center"><q-toggle dense v-model="t.on_call" :true-value="1" :false-value="0" color="brown"><q-tooltip>Quart de garde (urgences) — non offert au booking</q-tooltip></q-toggle></td>
|
||||
<td><q-btn flat dense round size="sm" icon="save" color="primary" @click="saveShiftTpl(t)"><q-tooltip>Enregistrer</q-tooltip></q-btn><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="delShiftTpl(t)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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="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" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Dialogue « Types de shift » (modèles de quart) — extrait de PlanificationPage (décomposition #4).
|
||||
// Édite les Shift Template via l'API roster ; `editTpls` = copie d'édition locale.
|
||||
// Après CHAQUE mutation → emit `changed` : le PARENT recharge sa liste partagée `templates`
|
||||
// (lue par la grille / garde / demande). Semé instantanément depuis la prop `templates` à l'ouverture.
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
templates: { type: Array, default: () => [] }, // liste partagée (LECTURE) — sème editTpls à l'ouverture
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'changed'])
|
||||
const $q = useQuasar()
|
||||
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
|
||||
|
||||
// Helpers — copies locales (les originaux restent partagés dans la page ; on ne les déplace pas).
|
||||
const 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 }
|
||||
const hToNum = (t) => { if (!t) return null; const p = String(t).split(':'); return Number(p[0]) + (Number(p[1]) || 0) / 60 }
|
||||
const fmtH = (h) => { const hh = Math.floor(h); const mm = Math.round((h - hh) * 60); return mm ? (hh + ':' + String(mm).padStart(2, '0')) : ('' + hh) }
|
||||
const 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 chip = (color) => ({ background: color || '#1976d2', color: '#fff' })
|
||||
|
||||
const open = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
|
||||
const editTpls = ref([])
|
||||
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 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 mapTpl = (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 })
|
||||
async function reloadFromServer () { try { editTpls.value = ((await roster.listTemplates()).templates || []).map(mapTpl) } catch (e) { err(e) } }
|
||||
|
||||
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 }); emit('changed'); $q.notify({ type: 'positive', message: t.template_name + ' enregistré (' + calcHours(t.start, t.end) + ' h)' }) } 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; emit('changed'); await reloadFromServer(); $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); emit('changed'); editTpls.value = editTpls.value.filter(x => x.name !== t.name); $q.notify({ type: 'info', message: 'Type supprimé' }) } catch (e) { err(e) } }
|
||||
|
||||
// Semer editTpls à l'ouverture depuis la liste partagée déjà chargée (remplace l'ancien openShiftEditor).
|
||||
watch(() => props.modelValue, (v) => { if (v) editTpls.value = (props.templates || []).map(mapTpl) })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Copiés de PlanificationPage (le CSS scoped ne traverse pas vers l'enfant). */
|
||||
.demand-tbl { border-collapse: collapse; }
|
||||
.demand-tbl th { font-size: 11px; color: #888; font-weight: 600; padding: 2px 6px; text-align: left; }
|
||||
.demand-tbl td { padding: 2px 4px; }
|
||||
.code-chip { display: inline-block; min-width: 18px; padding: 1px 5px; border-radius: 4px; font-weight: 700; font-size: 11px; line-height: 16px; margin: 1px; }
|
||||
</style>
|
||||
|
|
@ -182,7 +182,7 @@
|
|||
<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="openShiftEditor" />
|
||||
<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>
|
||||
|
|
@ -618,41 +618,8 @@
|
|||
|
||||
<!-- Matrice « Couverture — dispo vs requis » retirée : le besoin = toutes les spécialités chaque jour (règle simple, à automatiser via AI plus tard). -->
|
||||
|
||||
<q-dialog v-model="showShiftEditor">
|
||||
<q-card style="min-width:580px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-subtitle1 text-weight-bold">Types de shift</div><q-space />
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<table class="demand-tbl">
|
||||
<thead><tr><th>Nom</th><th>Début</th><th>Fin</th><th>Heures</th><th>Couleur</th><th>🛡️ Garde</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="t in editTpls" :key="t.name">
|
||||
<td><span class="code-chip" :style="chip(t.color)">{{ (t.template_name||'?')[0].toUpperCase() }}</span> {{ t.template_name }}</td>
|
||||
<td><q-input dense outlined type="time" v-model="t.start" style="width:115px" /></td>
|
||||
<td><q-input dense outlined type="time" v-model="t.end" style="width:115px" /></td>
|
||||
<td class="text-center text-weight-medium">{{ calcHours(t.start, t.end) }} h</td>
|
||||
<td><input type="color" v-model="t.color" style="width:36px;height:26px;border:none;background:none" /></td>
|
||||
<td class="text-center"><q-toggle dense v-model="t.on_call" :true-value="1" :false-value="0" color="brown"><q-tooltip>Quart de garde (urgences) — non offert au booking</q-tooltip></q-toggle></td>
|
||||
<td><q-btn flat dense round size="sm" icon="save" color="primary" @click="saveShiftTpl(t)"><q-tooltip>Enregistrer</q-tooltip></q-btn><q-btn flat dense round size="sm" icon="delete" color="grey-7" @click="delShiftTpl(t)" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</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="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" />
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<!-- Types de shift — extrait dans components/planif/ShiftTypesDialog.vue (décomposition #4) ; @changed → recharge les templates partagés -->
|
||||
<ShiftTypesDialog v-model="showShiftEditor" :templates="templates" @changed="refreshTemplates" />
|
||||
|
||||
<!-- Congés & disponibilités — extrait dans components/planif/LeaveDialog.vue (décomposition #4) -->
|
||||
<LeaveDialog v-model="showLeave" :techs="techs" :tech-options="techOptions" />
|
||||
|
|
@ -1988,6 +1955,7 @@ import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-
|
|||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (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 TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
||||
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
|
||||
|
|
@ -2115,7 +2083,7 @@ const hiddenTechs = ref([]); const showHidden = ref(false)
|
|||
function isHidden (id) { return hiddenTechs.value.includes(id) }
|
||||
function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) }
|
||||
const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length)
|
||||
const showShiftEditor = ref(false); const editTpls = ref([])
|
||||
const showShiftEditor = ref(false) // v-model de <ShiftTypesDialog> (editTpls/newTpl/fonctions déplacés dans le composant)
|
||||
const showTeamEditor = ref(false); const editTechs = ref([])
|
||||
const notifySms = ref(false)
|
||||
// ── Notifier les techs de leur tournée (SMS/Email + lien token) : aperçu d'abord (compose sans envoi), puis envoi. ──
|
||||
|
|
@ -2161,10 +2129,8 @@ async function saveNotifyContact (m) {
|
|||
} catch (e) { err(e) } finally { m.saving = false }
|
||||
}
|
||||
const showLeave = ref(false) // ouverture de <LeaveDialog> (état leaveRows/leaveFilter/newLeave + fonctions déplacés dans le composant)
|
||||
const newTpl = reactive({ template_name: '', start: '08:00', end: '16:00', color: '#1976d2', on_call: 0 })
|
||||
// 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') }
|
||||
// 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'; const LS_GARDE = 'roster-garde-rules-v1'; const LS_GARDE_MANUAL = 'roster-garde-manual-v1'
|
||||
|
||||
|
|
@ -2705,7 +2671,7 @@ const gardeEffective = computed(() => {
|
|||
function onGarde (techId, iso) { return !!gardeEffective.value[techId + '|' + iso] }
|
||||
// Nb de techs de garde par jour (garde EFFECTIVE) → ligne de pied cohérente avec la grille
|
||||
const gardeCountByDate = computed(() => { const m = {}; for (const k in gardeEffective.value) { const iso = k.split('|')[1]; m[iso] = (m[iso] || 0) + 1 } return m })
|
||||
function chip (color) { return { background: color || '#1976d2', color: '#fff' } }
|
||||
// chip() (pastille couleur du type de shift) déplacé dans ShiftTypesDialog — seul consommateur.
|
||||
|
||||
// techs visibles (recherche + groupe + tri)
|
||||
const groupOptions = computed(() => { const s = new Set(); for (const t of techs.value) if (t.group) s.add(t.group); return [...s].sort().map(g => ({ label: g, value: g })) })
|
||||
|
|
@ -5719,10 +5685,7 @@ async function saveCost (t) { try { await roster.setTechCost(t.id, { salary: t.s
|
|||
|
||||
// éditeur de types de shift (intervalle d'heures)
|
||||
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 () { 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) } }
|
||||
// Types de shift → extrait dans components/planif/ShiftTypesDialog.vue (état + fonctions déplacés ; @changed → refreshTemplates).
|
||||
function snapshotServer (list) { serverSet.value = new Set(list.map(a => a.tech + '|' + a.date + '|' + a.shift)) }
|
||||
async function loadWeek () {
|
||||
loading.value = true
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user