feat(planif): weekly shift generator per tech (templates + N weeks) — reworked from Dispatch
The best schedule editor lived in the (to-be-deleted) Dispatch page.
Reworked it into a shared Quasar WeeklyScheduleEditor.vue:
- Reuses the shared useHelpers.SCHEDULE_PRESETS (5×8h / 4×10h / 3×12h)
as one-click chips that fill a per-day form (toggle + start/end + hrs).
- "Générer pour N semaine(s)" → automation: repeats the pattern over N
weeks from the displayed week.
- Emits the schedule; Planif writes Shift Assignments DIRECTLY (published)
via ensureWindowTpl (per distinct window) + roster.createShift
(idempotent), then refreshes the grid. No Publier round-trip.
Wired into the tech skill dialog ("Générer l'horaire…", + a "5×8h
rapide" quick path). This is what makes the créneau/skill-shift feature
usable (regular shifts now exist). Dispatch's schedule modal can be
retired — its useful bits (SCHEDULE_PRESETS) were already shared.
Verified: editor opens per tech with presets + 7 day rows + weeks input;
matches the target UX in Planification.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8b9d5376a7
commit
b1ff46e8c1
83
apps/ops/src/components/shared/WeeklyScheduleEditor.vue
Normal file
83
apps/ops/src/components/shared/WeeklyScheduleEditor.vue
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script setup>
|
||||
/**
|
||||
* WeeklyScheduleEditor — génération de quarts HEBDO par tech, à partir de MODÈLES.
|
||||
* Reprend la meilleure UX (page Dispatch, qui sera supprimée) en Quasar + modèles partagés
|
||||
* (useHelpers.SCHEDULE_PRESETS) : chips de modèle → remplit le formulaire par jour (case + heures),
|
||||
* + « nombre de semaines » pour AUTOMATISER (répéter le patron sur N semaines).
|
||||
* Émet 'apply' { schedule:[{dow,on,start,end}] (Lun..Dim), weeks } → le parent écrit les Shift Assignment.
|
||||
*/
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { SCHEDULE_PRESETS } from 'src/composables/useHelpers'
|
||||
|
||||
const props = defineProps({ modelValue: { type: Boolean, default: false }, techName: { type: String, default: '' } })
|
||||
const emit = defineEmits(['update:modelValue', 'apply'])
|
||||
|
||||
const DAYS = [['mon', 'Lun'], ['tue', 'Mar'], ['wed', 'Mer'], ['thu', 'Jeu'], ['fri', 'Ven'], ['sat', 'Sam'], ['sun', 'Dim']]
|
||||
const sched = reactive({})
|
||||
const weeks = ref(1)
|
||||
|
||||
function fill (preset) { for (const [k] of DAYS) { const s = preset[k]; sched[k] = s ? { on: true, start: s.start, end: s.end } : { on: false, start: '08:00', end: '16:00' } } }
|
||||
fill(SCHEDULE_PRESETS[0].schedule)
|
||||
watch(() => props.modelValue, (o) => { if (o) { fill(SCHEDULE_PRESETS[0].schedule); weeks.value = 1 } })
|
||||
|
||||
function dayHours (d) { if (!d || !d.on) return 0; const [h1, m1] = d.start.split(':').map(Number); const [h2, m2] = d.end.split(':').map(Number); let mn = (h2 * 60 + m2) - (h1 * 60 + m1); if (mn < 0) mn += 1440; return Math.round(mn / 60 * 10) / 10 }
|
||||
const totalDays = () => DAYS.filter(([k]) => sched[k]?.on).length
|
||||
const totalHours = () => DAYS.reduce((s, [k]) => s + dayHours(sched[k]), 0)
|
||||
function submit () { emit('apply', { schedule: DAYS.map(([k]) => ({ dow: k, ...sched[k] })), weeks: Math.max(1, Math.min(12, Number(weeks.value) || 1)) }); emit('update:modelValue', false) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)">
|
||||
<q-card style="width:600px;max-width:96vw">
|
||||
<q-card-section class="row items-center q-pb-sm">
|
||||
<q-icon name="event_note" color="primary" size="24px" class="q-mr-sm" />
|
||||
<div class="text-h6">Horaire — {{ techName }}</div>
|
||||
<q-space /><q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<!-- Modèles (1 clic remplit le formulaire) -->
|
||||
<div class="row q-gutter-xs">
|
||||
<span v-for="p in SCHEDULE_PRESETS" :key="p.key" class="wse-preset" @click="fill(p.schedule)">{{ p.label }}</span>
|
||||
</div>
|
||||
<!-- Jours -->
|
||||
<div class="wse-grid">
|
||||
<div v-for="[k, lbl] in DAYS" :key="k" class="wse-day" :class="{ off: !sched[k]?.on }">
|
||||
<q-checkbox dense v-model="sched[k].on" :label="lbl" class="wse-day-lbl" />
|
||||
<template v-if="sched[k]?.on">
|
||||
<input type="time" v-model="sched[k].start" class="wse-time" />
|
||||
<span class="wse-sep">→</span>
|
||||
<input type="time" v-model="sched[k].end" class="wse-time" />
|
||||
<span class="wse-h">{{ dayHours(sched[k]) }}h</span>
|
||||
</template>
|
||||
<span v-else class="wse-repos">Repos</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Automatisation : répéter sur N semaines -->
|
||||
<div class="row items-center q-gutter-sm q-pt-xs">
|
||||
<q-input dense outlined type="number" v-model.number="weeks" label="Générer pour N semaine(s)" :min="1" :max="12" style="width:220px">
|
||||
<template #prepend><q-icon name="repeat" size="18px" /></template>
|
||||
</q-input>
|
||||
<div class="text-caption text-grey-6">à partir de la semaine affichée · {{ totalDays() }} j / {{ Math.round(totalHours() * 10) / 10 }}h par semaine</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right" class="q-px-md q-pb-md">
|
||||
<q-btn flat no-caps label="Annuler" color="grey-7" @click="emit('update:modelValue', false)" />
|
||||
<q-btn unelevated no-caps color="primary" icon="event_available" :label="'Générer ' + (weeks > 1 ? weeks + ' semaines' : 'la semaine')" @click="submit" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wse-preset { display: inline-flex; align-items: center; padding: 4px 12px; border: 1.5px solid #c7d2fe; border-radius: 14px; font-size: 12.5px; font-weight: 600; color: #4338ca; background: #eef2ff; cursor: pointer; user-select: none; }
|
||||
.wse-preset:hover { background: #e0e7ff; }
|
||||
.wse-grid { display: flex; flex-direction: column; gap: 4px; }
|
||||
.wse-day { display: flex; align-items: center; gap: 8px; padding: 4px 8px; border-radius: 8px; background: #f8fafc; }
|
||||
.wse-day.off { opacity: 0.7; }
|
||||
.wse-day-lbl { min-width: 62px; font-weight: 600; }
|
||||
.wse-time { border: 1px solid #cbd5e1; border-radius: 6px; padding: 3px 6px; font-size: 13px; font-family: inherit; }
|
||||
.wse-sep { color: #94a3b8; }
|
||||
.wse-h { color: #64748b; font-size: 12px; font-weight: 600; margin-left: auto; }
|
||||
.wse-repos { color: #94a3b8; font-style: italic; font-size: 12.5px; margin-left: 8px; }
|
||||
</style>
|
||||
|
|
@ -790,11 +790,10 @@
|
|||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Efficacité</b> : <b>+</b> plus vite / <b>−</b> plus lent pour CETTE compétence (ex. <b>+80</b>), vide = globale · × sur le chip = retirer.</div>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire — semaine affichée</div>
|
||||
<div class="row q-gutter-xs">
|
||||
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h (L–V)" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)" />
|
||||
<q-btn dense outline size="sm" no-caps color="primary" label="4×10h (L–J)" @click="applyWeekPreset(skillDialog, [1,2,3,4], 7, 17)" />
|
||||
<q-btn dense outline size="sm" no-caps color="primary" label="3×12h (L–M)" @click="applyWeekPreset(skillDialog, [1,2,3], 7, 19)" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-btn dense unelevated size="sm" no-caps color="primary" icon="event_note" label="Générer l'horaire…" @click="openSchedGen(skillDialog)"><q-tooltip>Modèles (5×8h · 4×10h · 3×12h) + heures par jour + N semaines → publie les quarts</q-tooltip></q-btn>
|
||||
<q-btn dense outline size="sm" no-caps color="primary" label="5×8h rapide" @click="applyWeekPreset(skillDialog, [1,2,3,4,5], 8, 16)"><q-tooltip>Applique 5×8h à la semaine affichée (à publier)</q-tooltip></q-btn>
|
||||
</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🏠 Domicile <span class="text-grey-5 text-weight-regular">(départ de tournée si plus proche que le dépôt)</span></div>
|
||||
|
|
@ -1734,6 +1733,8 @@
|
|||
:technicians="techs" :external-tags="tagCatalog" :external-get-color="getTagColor" @created="onJobCreated" />
|
||||
<!-- « Trouver un créneau » → chips compétence (durée par défaut) + adresse → choisit tech+date+heure puis pré-remplit la création -->
|
||||
<SuggestSlotsDialog v-model="showSuggestSlots" :skills="slotSkills" @select="onSlotSelected" />
|
||||
<!-- Génération de quarts hebdo par tech (modèles + N semaines) → écrit les Shift Assignment -->
|
||||
<WeeklyScheduleEditor v-model="schedGenOpen" :tech-name="schedGenTech && schedGenTech.name" @apply="onScheduleApply" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
|
|
@ -1777,6 +1778,7 @@ import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags pa
|
|||
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 UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) — Dispatch déprécié, tout ici
|
||||
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » → pré-remplit la création (tech+date+heure+adresse)
|
||||
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) — repris/amélioré depuis Dispatch
|
||||
|
||||
const $q = useQuasar()
|
||||
const router = useRouter()
|
||||
|
|
@ -2333,6 +2335,29 @@ async function applyWeekPreset (t, dows, min, max) {
|
|||
skillMenuShown.value = false
|
||||
$q.notify({ type: 'positive', message: t.name + ' : horaire appliqué (semaine affichée) — pense à Publier', timeout: 2500 })
|
||||
}
|
||||
// ── Génération de quarts HEBDO par tech (modèles + N semaines) — écrit DIRECTEMENT les Shift Assignment (Publié). ──
|
||||
const schedGenOpen = ref(false); const schedGenTech = ref(null)
|
||||
function openSchedGen (t) { schedGenTech.value = { id: t.id, name: t.name }; skillMenuShown.value = false; schedGenOpen.value = true }
|
||||
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
|
||||
async function onScheduleApply ({ schedule, weeks }) {
|
||||
const t = schedGenTech.value; if (!t) return
|
||||
let created = 0, failed = 0; const tplByKey = {}
|
||||
const base = start.value // lundi de la semaine affichée
|
||||
for (let w = 0; w < weeks; w++) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = schedule[i]; if (!day || !day.on) continue
|
||||
const sh = _hmNum(day.start), eh = _hmNum(day.end); if (!(eh > sh)) { failed++; continue }
|
||||
const key = sh + '-' + eh; let tpl = tplByKey[key]
|
||||
if (!tpl) { tpl = await ensureWindowTpl(sh, eh); tplByKey[key] = tpl }
|
||||
if (!tpl) { failed++; continue }
|
||||
const iso = addDaysISO(base, w * 7 + i)
|
||||
try { const r = await roster.createShift({ tech: t.id, tech_name: t.name, date: iso, shift: tpl.name, hours: tpl.hours || calcHours(day.start, day.end) }); if (r && (r.ok || r.existed)) created++; else failed++ } catch (e) { failed++ }
|
||||
}
|
||||
}
|
||||
try { await loadWeek() } catch (e) {} // rafraîchit la grille (semaine affichée)
|
||||
$q.notify({ type: created ? 'positive' : 'warning', icon: 'event_available', message: created + ' quart(s) publié(s) pour ' + t.name + (weeks > 1 ? ' (' + weeks + ' sem.)' : '') + (failed ? ' · ' + failed + ' ignoré(s)' : ''), timeout: 3500 })
|
||||
schedGenTech.value = null
|
||||
}
|
||||
function skillEffOf (t, sk) { const e = t.skill_eff && t.skill_eff[sk]; return (e != null && e !== '') ? Number(e) : (Number(t.efficiency) || 1) } // facteur (pour le calcul de priorité)
|
||||
// Efficacité saisie en % de PERFORMANCE : + = plus VITE (moins de temps) · − = plus LENT. Conversion ↔ facteur.
|
||||
function effPctOf (factor) { return Math.round((1 - (Number(factor) || 1)) * 100) }
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user