feat: suivi généralisé, occupation ressources, horaire tech, revert journal

Follow / subscribe (au-delà des tickets)
- hub: store généralisé /conversations/follow {doctype,name} (follows.json,
  migre ticket_follows.json sous Issue) ; ticket-follow = alias Issue
- InterveneDialog: tuile « Suivre » câblée (toggle + état) ; TicketsPage sync

« Trouver un créneau »
- hub: /dispatch/occupancy (techOccupancy) — occupation par tech filtrée skill
- SuggestSlotsDialog: panneau occupation (bandes verticales) + props de
  pré-remplissage (adresse/coords/skill)
- ClientDetailPage: entrée directe « Trouver un créneau » (appel/réparation)
- fix moteur créneaux: suggestSlots + techOccupancy respectent désormais les
  congés (Tech Availability approuvé), « En pause » et « Archivé »

Horaire par technicien (components/planif/TechScheduleDialog)
- écran unique: pause indéfinie + motif · horaire récurrent (réutilise
  WeeklyScheduleEditor) · calendrier du mois (cases carrées, glisser-
  sélectionner + pinceau congé/maladie/indispo/effacer) · archivage réversible
- hub: /roster/technician/:id/archive (status Archivé) + exclusion au fetch
  (_fetchTechniciansRaw) + /roster/technicians?archived=1 (restauration)
- icône « event » de la rangée tech remplace le bouton pause

Journal des changements (icône history)
- chaque changement de quart révertible individuellement (bouton « annuler »),
  à la façon de la liste « Publier »

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-08 12:40:22 -04:00
parent 77343d0a98
commit 13039be301
11 changed files with 697 additions and 29 deletions

View File

@ -17,6 +17,16 @@ export async function suggestSlots (payload) {
return data.slots || data || []
}
// Occupation par ressource (tech) sur l'horizon, filtrée compétence → bandes verticales « Trouver un créneau ».
// payload = { after_date?, days?, skill? } → { start, days, dates[], techs: [{ tech_id, tech_name, occupancy, shift_h, busy_h, free_h, days:[{date,dow,off,reason,occupancy,shift_start,shift_end,shift_h,busy_h,free_h,jobs,blocks:[{top,height,start,end,reserved}]}] }] }
export async function techOccupancy (payload) {
const res = await fetch(`${HUB_URL}/dispatch/occupancy`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}),
})
if (!res.ok) throw new Error('occupancy ' + res.status)
return res.json()
}
async function apiGet (path) {
const res = await authFetch(BASE_URL + path)
const data = await res.json()

View File

@ -76,6 +76,7 @@ export const listAvailability = (status) => jget('/roster/availability' + (statu
export const requestAvailability = (a) => jpost('/roster/availability', a)
export const approveAvailability = (name, body) => jpost('/roster/availability/' + encodeURIComponent(name) + '/approve', body || {})
export const pauseTechnician = (id, paused, reason) => jpost(`/roster/technician/${encodeURIComponent(id)}/pause`, { paused, reason })
export const archiveTechnician = (id, archived) => jpost(`/roster/technician/${encodeURIComponent(id)}/archive`, { archived }) // archive réversible (masqué du dispatch/solveur/créneaux, historique conservé)
export const setTechEfficiency = (id, efficiency) => jpost(`/roster/technician/${encodeURIComponent(id)}/efficiency`, { efficiency })
export const setTechCost = (id, body) => jpost(`/roster/technician/${encodeURIComponent(id)}/cost`, body)
export const setTechSkills = (id, skills, skillLevels, skillEff) => { const body = { skills }; if (skillLevels !== undefined) body.skill_levels = skillLevels; if (skillEff !== undefined) body.skill_eff = skillEff; return jpost(`/roster/technician/${encodeURIComponent(id)}/skills`, body) }

View File

@ -0,0 +1,271 @@
<template>
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)">
<q-card style="width:640px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="event" color="primary" size="22px" class="q-mr-sm" />
<div>
<div class="text-subtitle1 text-weight-bold">Horaire {{ tech && tech.name }}</div>
<div class="text-caption text-grey-6">Horaire récurrent · pause indéfinie · congés du mois</div>
</div>
<q-space />
<q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
</q-card-section>
<q-card-section class="q-gutter-md">
<!-- 1. Pause indéfinie (maladie / maternité / épuisement) -->
<div class="ts-block">
<div class="row items-center">
<q-icon name="pause_circle" :color="paused ? 'orange-8' : 'grey-6'" size="20px" class="q-mr-sm" />
<div class="col">
<div class="text-weight-medium">Pause indéfinie</div>
<div class="text-caption text-grey-6">Retire le tech du dispatch et du solveur jusqu'à réactivation (arrêt maladie, maternité, épuisement).</div>
</div>
<q-toggle :model-value="paused" color="orange-8" :disable="pauseBusy" @update:model-value="onTogglePause" />
</div>
<div v-if="paused" class="row items-center q-gutter-sm q-mt-sm">
<q-select dense outlined v-model="pauseReason" :options="PAUSE_REASONS" label="Motif" style="width:200px" @update:model-value="savePauseReason" />
<q-input dense outlined v-model="pauseNote" label="Note (optionnel)" class="col" @blur="savePauseReason" @keyup.enter="savePauseReason" />
</div>
</div>
<!-- 2. Horaire récurrent (réutilise WeeklyScheduleEditor via le parent) -->
<div class="ts-block row items-center">
<q-icon name="repeat" color="teal-7" size="20px" class="q-mr-sm" />
<div class="col">
<div class="text-weight-medium">Horaire récurrent</div>
<div class="text-caption text-grey-6">Patron hebdo (jours & heures) qui matérialise les quarts sur plusieurs semaines.</div>
</div>
<q-btn unelevated no-caps color="teal-7" icon="edit_calendar" label="Modifier" :disable="paused" @click="emit('edit-schedule', tech)" />
</div>
<!-- 3. Calendrier du mois : cases carrées, glisser-sélectionner + pinceau -->
<div class="ts-block">
<div class="row items-center q-mb-sm">
<q-btn flat round dense icon="chevron_left" @click="shiftMonth(-1)" />
<div class="text-weight-medium" style="min-width:150px;text-align:center;text-transform:capitalize">{{ monthLabel }}</div>
<q-btn flat round dense icon="chevron_right" @click="shiftMonth(1)" />
<q-space />
<q-spinner v-if="calLoading" size="18px" color="primary" />
</div>
<!-- Pinceau -->
<div class="row items-center q-gutter-xs q-mb-xs">
<span class="text-caption text-grey-7 q-mr-xs">Pinceau :</span>
<span v-for="b in BRUSHES" :key="b.type" class="ts-brush" :class="{ on: brush === b.type }"
:style="brush === b.type ? { background: b.color, borderColor: b.color, color: '#fff' } : { borderColor: b.color, color: b.color }"
@click="brush = b.type">{{ b.label }}</span>
<q-space /><span class="text-caption text-grey-5">glisse sur les jours pour appliquer</span>
</div>
<!-- Grille -->
<div class="ts-cal" @mouseleave="endDrag" @mouseup="endDrag">
<div v-for="d in DOW_LABELS" :key="'h' + d" class="ts-dowh">{{ d }}</div>
<div v-for="(c, i) in monthCells" :key="i"
class="ts-cell" :class="cellClass(c)"
@mousedown.prevent="c.inMonth && startDrag(c)"
@mouseenter="c.inMonth && overDrag(c)">
<template v-if="c.inMonth">
<span class="ts-daynum">{{ c.day }}</span>
<span v-if="c.absType" class="ts-abs" :style="{ background: absColor(c.absType) }"><q-tooltip>{{ c.absType }}</q-tooltip></span>
<span v-if="c.holiday" class="ts-holi" title="Férié"></span>
</template>
</div>
</div>
<div class="row items-center q-gutter-md q-mt-xs ts-legend">
<span v-for="b in BRUSHES.filter(x => x.type !== 'clear')" :key="'l' + b.type"><i class="ts-dot" :style="{ background: b.color }"></i>{{ b.label }}</span>
<span><i class="ts-dot ts-dot-today"></i>aujourd'hui</span>
<q-space /><span class="text-grey-6" v-if="selCount">{{ selCount }} jour(s) sélectionné(s)</span>
</div>
</div>
</q-card-section>
<q-separator />
<q-card-actions align="left" class="q-pa-md">
<q-btn flat no-caps color="negative" icon="archive" label="Archiver le technicien" :loading="archiveBusy" @click="confirmArchive" />
<q-space />
<q-btn flat no-caps color="grey-7" label="Fermer" @click="emit('update:modelValue', false)" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script setup>
// Écran UNIQUE de gestion d'horaire d'un technicien : pause indéfinie (motif) + horaire récurrent (délègue à
// WeeklyScheduleEditor du parent) + calendrier du mois (cases carrées, glisser-sélectionner + pinceau) pour les
// congés/absences ponctuels, et archivage (réversible). Réutilise les endpoints roster déjà déployés.
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 },
tech: { type: Object, default: null }, // { id, name, status, ... }
})
const emit = defineEmits(['update:modelValue', 'edit-schedule', 'changed'])
const $q = useQuasar()
const err = (e) => $q.notify({ type: 'negative', message: '' + (e.message || e) })
const PAUSE_REASONS = ['Arrêt maladie', 'Maternité', 'Épuisement', 'Invalidité', 'Autre']
const BRUSHES = [
{ type: 'Congé', label: 'Congé', color: '#0ea5e9' },
{ type: 'Maladie', label: 'Maladie', color: '#ef4444' },
{ type: 'Indisponible', label: 'Indispo', color: '#a855f7' },
{ type: 'clear', label: 'Effacer', color: '#94a3b8' },
]
const DOW_LABELS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
// Pause
const paused = ref(false)
const pauseReason = ref('Arrêt maladie')
const pauseNote = ref('')
const pauseBusy = ref(false)
function reasonPayload () { return [pauseReason.value, pauseNote.value].filter(Boolean).join(' — ') }
async function onTogglePause (v) {
if (!props.tech) return
pauseBusy.value = true
try {
await roster.pauseTechnician(props.tech.id, v, v ? reasonPayload() : '')
paused.value = v
$q.notify({ type: 'info', message: props.tech.name + (v ? ' en pause' : ' réactivé') })
emit('changed', { id: props.tech.id, status: v ? 'En pause' : 'Disponible' })
} catch (e) { err(e) } finally { pauseBusy.value = false }
}
async function savePauseReason () {
if (!props.tech || !paused.value) return
try { await roster.pauseTechnician(props.tech.id, true, reasonPayload()) } catch (e) { /* best-effort */ }
}
// Calendrier du mois
const brush = ref('Congé')
const month = ref('') // 'YYYY-MM'
const calLoading = ref(false)
const absMap = ref({}) // 'YYYY-MM-DD' type d'absence
const holiSet = ref(new Set())
const drag = reactive({ on: false, sel: new Set() })
const selCount = computed(() => drag.sel.size)
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' }) }
function pad (n) { return String(n).padStart(2, '0') }
const monthCells = computed(() => {
if (!month.value) return []
const [y, m] = month.value.split('-').map(Number)
const first = new Date(Date.UTC(y, m - 1, 1))
const lead = (first.getUTCDay() + 6) % 7 // lundi = 0
const daysInMonth = new Date(Date.UTC(y, m, 0)).getUTCDate()
const cells = []
for (let i = 0; i < lead; i++) cells.push({ inMonth: false })
const t = todayISO()
for (let day = 1; day <= daysInMonth; day++) {
const iso = y + '-' + pad(m) + '-' + pad(day)
const dow = new Date(iso + 'T12:00:00').getUTCDay()
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) })
}
while (cells.length % 7) cells.push({ inMonth: false })
return cells
})
function cellClass (c) {
if (!c.inMonth) return 'ts-cell-empty'
return { weekend: c.weekend, today: c.isToday, sel: drag.sel.has(c.iso), off: !!c.absType }
}
function absColor (type) { const b = BRUSHES.find(x => x.type === type); return b ? b.color : '#94a3b8' }
function shiftMonth (n) {
const [y, m] = month.value.split('-').map(Number)
const d = new Date(Date.UTC(y, m - 1 + n, 1))
month.value = d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1)
loadCal()
}
async function loadCal () {
if (!props.tech || !month.value) return
calLoading.value = true
try {
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] = await Promise.all([
roster.getAbsences(start, days),
roster.holidays(start, y + '-' + pad(m) + '-' + pad(days)),
])
const out = {}
const abs = (absRes && absRes.absences) || {}
for (const [k, type] of Object.entries(abs)) {
const [tid, date] = k.split('|')
if (tid === props.tech.id || tid === props.tech.name) out[date] = type
}
absMap.value = out
holiSet.value = new Set((holiRes && (holiRes.holidays || [])).map(h => h.date || h))
} 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 () {
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 } }
}
// Archivage (réversible)
const archiveBusy = ref(false)
function confirmArchive () {
$q.dialog({
title: 'Archiver le technicien',
message: `${props.tech?.name} sera masqué du dispatch, du solveur et de la recherche de créneaux. L'historique est conservé — réversible depuis « Congés & disponibilités ».`,
cancel: { label: 'Annuler', flat: true }, ok: { label: 'Archiver', color: 'negative', unelevated: true }, persistent: true,
}).onOk(async () => {
archiveBusy.value = true
try {
await roster.archiveTechnician(props.tech.id, true)
$q.notify({ type: 'info', message: props.tech.name + ' archivé' })
emit('changed', { id: props.tech.id, archived: true })
emit('update:modelValue', false)
} catch (e) { err(e) } finally { archiveBusy.value = false }
})
}
// Ouverture : synchronise l'état pause + charge le mois courant.
watch(() => props.modelValue, (o) => {
if (!o) return
paused.value = props.tech ? props.tech.status === 'En pause' : false
pauseNote.value = ''
const t = todayISO()
month.value = t.slice(0, 7)
absMap.value = {}; holiSet.value = new Set(); drag.on = false; drag.sel = new Set()
loadCal()
})
</script>
<style scoped>
.ts-block { border: 1px solid #e2e8f0; border-radius: 10px; padding: 12px; }
.ts-brush { display: inline-flex; align-items: center; padding: 2px 10px; border: 1.5px solid #cbd5e1; border-radius: 12px; font-size: 12px; font-weight: 600; cursor: pointer; user-select: none; background: #fff; }
.ts-cal { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; user-select: none; }
.ts-dowh { text-align: center; font-size: 10.5px; color: #64748b; font-weight: 700; padding-bottom: 2px; }
.ts-cell { position: relative; aspect-ratio: 1 / 1; border: 1px solid #e2e8f0; border-radius: 6px; cursor: pointer; display: flex; align-items: flex-start; justify-content: flex-start; padding: 3px 5px; background: #fff; transition: background .08s, border-color .08s; }
.ts-cell:hover { border-color: #2563eb; }
.ts-cell.weekend { background: #f8fafc; }
.ts-cell.today { border-color: #2563eb; box-shadow: inset 0 0 0 1px #2563eb; }
.ts-cell.sel { background: #dbeafe; border-color: #2563eb; }
.ts-cell.off { background: #fff7ed; }
.ts-cell-empty { border: none; background: transparent; cursor: default; }
.ts-daynum { font-size: 11.5px; color: #334155; font-weight: 600; }
.ts-abs { position: absolute; left: 4px; right: 4px; bottom: 4px; height: 5px; border-radius: 3px; }
.ts-holi { position: absolute; top: 2px; right: 3px; color: #b45309; font-size: 10px; }
.ts-legend { font-size: 10.5px; color: #475569; }
.ts-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
.ts-dot-today { background: #fff; border: 1.5px solid #2563eb; }
</style>

View File

@ -15,11 +15,13 @@
<!-- Écran 1 : options principales -->
<div v-if="step === 'menu'" class="row q-col-gutter-sm">
<div class="col-6" v-for="a in actions" :key="a.key">
<div class="iv-tile" :class="{ soon: a.soon }" @click="!a.soon && (step = a.key)">
<q-icon :name="a.icon" size="26px" :color="a.soon ? 'grey-5' : a.color" />
<div class="text-weight-medium q-mt-xs">{{ a.label }}</div>
<div class="text-caption text-grey-6">{{ a.hint }}</div>
<div class="iv-tile" :class="{ soon: a.soon, on: a.key === 'follow' && following }" @click="onTile(a)">
<q-icon :name="a.key === 'follow' ? (following ? 'notifications_active' : 'visibility') : a.icon"
size="26px" :color="a.soon ? 'grey-5' : (a.key === 'follow' && following ? 'blue-7' : a.color)" />
<div class="text-weight-medium q-mt-xs">{{ a.key === 'follow' ? (following ? 'Suivi' : 'Suivre') : a.label }}</div>
<div class="text-caption text-grey-6">{{ a.key === 'follow' && following ? 'Tu reçois les suites' : a.hint }}</div>
<q-badge v-if="a.soon" label="bientôt" color="grey-3" text-color="grey-6" class="q-mt-xs" />
<q-spinner v-if="a.key === 'follow' && followBusy" size="15px" color="primary" class="q-mt-xs" />
</div>
</div>
</div>
@ -75,10 +77,11 @@
import { ref } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
import { useAuthStore } from 'src/stores/auth'
import { shortAgent as shortName, formatDateTimeShort as fmt } from 'src/composables/useFormatters'
const props = defineProps({ modelValue: Boolean, doctype: { type: String, default: 'Issue' }, name: String, title: String })
const emit = defineEmits(['update:modelValue', 'posted'])
const emit = defineEmits(['update:modelValue', 'posted', 'followed'])
const $q = useQuasar()
const open = ref(false)
@ -96,9 +99,34 @@ const actions = [
{ key: 'note', icon: 'lightbulb', color: 'amber-7', label: 'Indice / note', hint: 'Partager une connaissance' },
{ key: 'mention', icon: 'alternate_email', color: 'primary', label: 'Mentionner', hint: 'Demander à un collègue' },
{ key: 'link', icon: 'link', color: 'teal-6', label: 'Lier un élément', hint: 'Ticket, client…', soon: true },
{ key: 'follow', icon: 'visibility', color: 'blue-grey-6', label: 'Suivre', hint: 'Recevoir les suites', soon: true },
{ key: 'follow', icon: 'visibility', color: 'blue-grey-6', label: 'Suivre', hint: 'Recevoir les suites' },
]
// Suivre : toggle (pas d'écran) (dé)suivre l'élément via le store généralisé. Le suivi « Issue » reste synchronisé avec ticket-follow.
const me = useAuthStore().user
const followHdr = () => (me && me !== 'authenticated' ? { 'Content-Type': 'application/json', 'X-Authentik-Email': me } : { 'Content-Type': 'application/json' })
const following = ref(false)
const followBusy = ref(false)
function onTile (a) { if (a.soon) return; if (a.key === 'follow') { toggleFollow(); return } step.value = a.key }
async function loadFollow () {
following.value = false
if (!props.name) return
try {
const r = await fetch(`${HUB_URL}/conversations/follow?doctype=${encodeURIComponent(props.doctype)}`, { headers: followHdr() }).then(x => x.json())
following.value = (r.follows || []).includes(props.name)
} catch (e) { /* */ }
}
async function toggleFollow () {
if (!props.name || followBusy.value) return
followBusy.value = true
const next = !following.value
try {
const r = await fetch(`${HUB_URL}/conversations/follow`, { method: 'POST', headers: followHdr(), body: JSON.stringify({ doctype: props.doctype, name: props.name, follow: next }) }).then(x => x.json())
if (r.ok) { following.value = r.following; $q.notify({ type: 'positive', message: following.value ? 'Tu suis cet élément — tu recevras les suites' : 'Suivi retiré' }); emit('followed', { name: props.name, doctype: props.doctype, following: following.value }) }
else $q.notify({ type: 'negative', message: r.error || 'Échec' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { followBusy.value = false }
}
// sync v-model
import { watch } from 'vue'
watch(() => props.modelValue, v => { open.value = v })
@ -108,7 +136,7 @@ function reset () { step.value = 'menu'; text.value = ''; picked.value = []; sea
// shortName = shortAgent, fmt = formatDateTimeShort (useFormatters, consolidation)
function strip (h) { return String(h || '').replace(/<[^>]+>/g, ' ').replace(/&lt;/g, '<').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim() }
async function onShow () { reset(); await loadActivity() }
async function onShow () { reset(); await Promise.all([loadActivity(), loadFollow()]) }
async function loadActivity () {
if (!props.name) return
try { const r = await fetch(`${HUB_URL}/collab/activity?doctype=${encodeURIComponent(props.doctype)}&name=${encodeURIComponent(props.name)}`); if (r.ok) { const d = await r.json(); activity.value = d.comments || [] } } catch (e) { /* */ }
@ -142,6 +170,8 @@ async function submit (mentions) {
.iv-tile:hover { border-color: #6366f1; background: #eef2ff; }
.iv-tile.soon { cursor: default; opacity: .6; }
.iv-tile.soon:hover { border-color: #e2e8f0; background: transparent; }
.iv-tile.on { border-color: #2563eb; background: #eff6ff; }
.iv-tile.on:hover { border-color: #2563eb; background: #dbeafe; }
.iv-dd { position: absolute; z-index: 20; left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); margin-top: 2px; max-height: 220px; overflow-y: auto; }
.iv-dd-item { display: flex; align-items: center; padding: 7px 10px; cursor: pointer; font-size: .85rem; }
.iv-dd-item:hover { background: #f1f5f9; }

View File

@ -9,12 +9,17 @@
*/
import { reactive, ref, watch } from 'vue'
import { Notify } from 'quasar'
import { suggestSlots } from 'src/api/dispatch'
import { suggestSlots, techOccupancy } from 'src/api/dispatch'
import { useAddressSearch } from 'src/composables/useAddressSearch'
const props = defineProps({
modelValue: { type: Boolean, default: false },
skills: { type: Array, default: () => [] }, // [{ skill, dur, color }]
// Pré-remplissage (appel client / réparation / soumission) : adresse + coords + compétence. L'agent n'a plus qu'à chercher.
initialAddress: { type: String, default: '' },
initialLat: { type: Number, default: null },
initialLng: { type: Number, default: null },
initialSkill: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'select', 'plan-shifts'])
@ -28,10 +33,42 @@ const loading = ref(false)
const slots = ref([])
const searched = ref(false)
// Reset à l'ouverture (formulaire propre à chaque fois)
watch(() => props.modelValue, (o) => { if (o) { target.address = ''; target.latitude = null; target.longitude = null; skill.value = ''; durationH.value = 1; urgent.value = false; slots.value = []; searched.value = false; addrResults.value = [] } })
// Occupation des ressources (bandes verticales)
// Panneau dépliable : occupation par tech sur 7 jours, FILTRÉE par la compétence choisie on voit qui a de la place
// AVANT même de saisir l'adresse (l'occupation ne dépend que du skill + de la date, pas du lieu).
const occOpen = ref(false)
const occ = ref(null)
const occLoading = ref(false)
let occTimer = null
async function loadOcc () {
occLoading.value = true
try { occ.value = await techOccupancy({ after_date: afterDate.value, days: 7, skill: skill.value }) }
catch (e) { occ.value = null; Notify.create({ type: 'negative', message: 'Occupation indisponible : ' + (e.message || e), timeout: 3000 }) }
finally { occLoading.value = false }
}
function scheduleOcc () { if (!occOpen.value) return; clearTimeout(occTimer); occTimer = setTimeout(loadOcc, 250) }
watch(occOpen, (v) => { if (v && !occ.value) loadOcc() })
watch([skill, afterDate], scheduleOcc) // re-filtre l'occupation quand la compétence ou la date change (si le panneau est ouvert)
// Reset à l'ouverture (formulaire propre à chaque fois) puis pré-remplissage éventuel (adresse/coords/compétence du contexte appelant).
watch(() => props.modelValue, (o) => {
if (!o) return
target.address = props.initialAddress || ''; target.latitude = props.initialLat; target.longitude = props.initialLng
skill.value = props.initialSkill || ''; durationH.value = 1; urgent.value = false
slots.value = []; searched.value = false; addrResults.value = []; occ.value = null; occOpen.value = false
// Adresse fournie SANS coords (ex. lieu de service texte) lance l'autosuggest RQA pour que l'agent confirme d'un clic.
if (props.initialAddress && props.initialLat == null) searchAddr(props.initialAddress)
})
function pickSkill (s) { skill.value = s.skill; durationH.value = s.dur || 1 } // 1 clic skill + durée par défaut
// Clic sur une case-jour de l'occupation cale la recherche sur ce jour (et lance si l'adresse est déjà choisie).
function onOccDay (cell) { if (!cell || cell.off) return; afterDate.value = cell.date; if (target.latitude != null) search() }
const pct = (o) => (o == null ? '—' : Math.round(o * 100) + '%')
const OFF_LABEL = { weekend: 'week-end', no_shift: 'aucun quart', absence: 'absent', vacation: 'congé' }
function offLabel (c) { return OFF_LABEL[c.reason] || 'indisponible' }
// Couleur d'occupation (vert = libre rouge = plein) pour la barre globale du tech.
function occColor (o) { if (o == null) return '#cbd5e1'; if (o < 0.5) return '#22c55e'; if (o < 0.8) return '#f59e0b'; return '#ef4444' }
function occShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) }
function onAddrInput (v) { target.latitude = null; target.longitude = null; searchAddr(v) } // retape l'adresse invalide les coords
function onPickAddr (a) { selectAddr(a, target); addrResults.value = [] }
@ -73,6 +110,46 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
@click="pickSkill(s)">{{ s.skill }}</span>
</div>
<!-- 1b. Occupation des ressources (dépliable) qui a de la place pour la compétence choisie, sur 7 jours -->
<q-expansion-item v-model="occOpen" dense switch-toggle-side class="ss-occ-exp"
icon="bar_chart" :label="skill ? 'Occupation — « ' + skill + ' »' : 'Occupation des ressources'"
:caption="occ ? (occ.techs.length + ' ressource' + (occ.techs.length > 1 ? 's' : '') + ' · dès ' + occShort(afterDate)) : 'Voir la charge par tech'">
<div class="q-pt-xs">
<div v-if="occLoading" class="text-center q-pa-sm"><q-spinner size="22px" color="primary" /></div>
<div v-else-if="!occ || !occ.techs.length" class="text-caption text-grey-6 q-pa-sm">Aucune ressource{{ skill ? ' pour « ' + skill + ' »' : '' }} sur l'horizon.</div>
<template v-else>
<div class="occ-grid" :style="{ '--cols': occ.dates.length }">
<!-- en-tête jours -->
<div class="occ-name occ-corner"></div>
<div v-for="d in occ.dates" :key="'h' + d" class="occ-dayh" :class="{ we: [0,6].includes(new Date(d + 'T12:00:00').getDay()) }">{{ occShort(d) }}</div>
<!-- une ligne par tech -->
<template v-for="t in occ.techs" :key="t.tech_id">
<div class="occ-name">
<div class="ellipsis text-weight-medium">{{ t.tech_name }}</div>
<div class="occ-bar"><div class="occ-bar-fill" :style="{ width: ((t.occupancy || 0) * 100) + '%', background: occColor(t.occupancy) }"></div></div>
<div class="occ-sub">{{ pct(t.occupancy) }} · {{ t.free_h }} h libre</div>
</div>
<div v-for="(c, i) in t.days" :key="t.tech_id + i" class="occ-cell" @click="onOccDay(c)">
<div v-if="c.off" class="occ-strip off"><q-tooltip>{{ occShort(c.date) }} · {{ offLabel(c) }}</q-tooltip></div>
<div v-else class="occ-strip" :class="{ clickable: true }">
<div v-for="(b, bi) in c.blocks" :key="bi" class="occ-block" :class="{ reserved: b.reserved }"
:style="{ top: (b.top * 100) + '%', height: Math.max(3, b.height * 100) + '%' }"></div>
<q-tooltip>{{ occShort(c.date) }} · {{ c.shift_start }}{{ c.shift_end }}<br>{{ pct(c.occupancy) }} occupé · {{ c.free_h }} h libre · {{ c.jobs }} job{{ c.jobs > 1 ? 's' : '' }}</q-tooltip>
</div>
</div>
</template>
</div>
<div class="row items-center q-gutter-md q-mt-xs occ-legend">
<span><i class="occ-dot job"></i> occupé</span>
<span><i class="occ-dot reserved"></i> réservé</span>
<span><i class="occ-dot free"></i> libre</span>
<span><i class="occ-dot offd"></i> pas de quart</span>
<q-space /><span class="text-grey-6">clic sur un jour cale la recherche</span>
</div>
</template>
</div>
</q-expansion-item>
<!-- 2. Adresse autosuggest RQA (liste sous le champ) -->
<q-input dense outlined v-model="target.address" label="Adresse du client" clearable autofocus
:loading="addrLoading" @update:model-value="onAddrInput">
@ -127,4 +204,27 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
<style scoped>
.ss-chip { display: inline-flex; align-items: center; padding: 3px 11px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12.5px; font-weight: 600; cursor: pointer; user-select: none; background: #fff; text-transform: capitalize; }
.ss-addr-list { border-radius: 6px; max-height: 168px; overflow-y: auto; }
/* ── Occupation : grille tech × jours, chaque case = bande verticale (mini-timeline du quart) ── */
.ss-occ-exp { border: 1px solid #e2e8f0; border-radius: 8px; }
.occ-grid { display: grid; grid-template-columns: 118px repeat(var(--cols), 1fr); gap: 3px; align-items: stretch; max-height: 42vh; overflow: auto; padding: 2px; }
.occ-corner { background: transparent; }
.occ-dayh { font-size: 10px; text-align: center; color: #64748b; font-weight: 600; padding-bottom: 2px; text-transform: capitalize; }
.occ-dayh.we { color: #b45309; }
.occ-name { min-width: 0; padding: 2px 4px 2px 0; display: flex; flex-direction: column; justify-content: center; font-size: 11.5px; }
.occ-name .ellipsis { max-width: 112px; }
.occ-bar { height: 4px; background: #e5e7eb; border-radius: 3px; overflow: hidden; margin: 2px 0; }
.occ-bar-fill { height: 100%; border-radius: 3px; transition: width .2s; }
.occ-sub { font-size: 9.5px; color: #64748b; }
.occ-cell { display: flex; }
.occ-strip { position: relative; flex: 1; min-height: 46px; border-radius: 4px; background: #dcfce7; border: 1px solid #bbf7d0; overflow: hidden; cursor: pointer; }
.occ-strip.off { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 4px, #e2e8f0 4px, #e2e8f0 8px); border-color: #e2e8f0; cursor: default; }
.occ-block { position: absolute; left: 1px; right: 1px; background: #2563eb; border-radius: 2px; }
.occ-block.reserved { background: #f59e0b; }
.occ-legend { font-size: 10.5px; color: #475569; }
.occ-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
.occ-dot.job { background: #2563eb; }
.occ-dot.reserved { background: #f59e0b; }
.occ-dot.free { background: #dcfce7; border: 1px solid #bbf7d0; }
.occ-dot.offd { background: #e2e8f0; }
</style>

View File

@ -24,6 +24,9 @@
</q-list>
</q-menu>
</q-btn>
<q-btn dense flat size="sm" no-caps color="teal-8" icon="event_available" label="Trouver un créneau" @click="openFindSlot">
<q-tooltip>Prendre RDV (réparation / installation) pré-rempli avec l'adresse du client</q-tooltip>
</q-btn>
<q-btn dense flat size="sm" no-caps color="warning" icon="card_giftcard" label="Récompense" @click="rewardOpen = true">
<q-tooltip>Carte-cadeau : voir / copier le lien, éditer ou envoyer par courriel</q-tooltip>
</q-btn>
@ -587,6 +590,12 @@
:context="ticketContext" :locations="locations"
@created="onTicketCreated" />
<!-- « Trouver un créneau » direct (appel / réparation) crée un Dispatch Job lié au client -->
<SuggestSlotsDialog v-model="findSlotOpen" :skills="slotSkills"
:initial-address="findSlotAddr" :initial-skill="findSlotSkill" @select="onFindSlotSelected" />
<UnifiedCreateModal v-model="createJobOpen" mode="work-order"
:context="createJobCtx" :locations="locations" @created="onJobCreated" />
<CreateInvoiceModal v-model="newInvoiceOpen" :customer="customer" @created="onInvoiceCreated" />
<ProjectWizard v-model="newQuotationOpen" :customer="quotationCustomerContext"
@ -695,6 +704,7 @@ import CustomerSatisfaction from 'src/components/customer/CustomerSatisfaction.v
import InlineField from 'src/components/shared/InlineField.vue'
import { useSoftphone } from 'src/composables/useSoftphone'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » direct depuis la fiche (appel/réparation)
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
@ -1182,6 +1192,43 @@ async function loadJobs () {
const c = customer.value; if (!c || !c.name) { jobs.value = []; return }
try { jobs.value = await listDocs('Dispatch Job', { filters: { customer: c.name }, fields: ['name', 'subject', 'status', 'scheduled_date', 'job_type', 'assigned_tech'], limit: 50, orderBy: 'modified desc' }) || [] } catch (e) { jobs.value = [] }
}
// « Trouver un créneau » DIRECT depuis la fiche client (appel entrant / réparation)
// Pré-remplit l'adresse du lieu de service principal + « réparation », puis le créneau choisi crée un Dispatch Job lié au client.
const findSlotOpen = ref(false)
const findSlotAddr = ref('')
const findSlotSkill = ref('')
const createJobOpen = ref(false)
const createJobCtx = ref({})
const slotSkills = [
{ skill: 'réparation', dur: 1, color: '#ef4444' },
{ skill: 'installation', dur: 2, color: '#f59e0b' },
{ skill: 'tv', dur: 1, color: '#8b5cf6' },
{ skill: 'téléphonie', dur: 0.5, color: '#0ea5e9' },
]
function primaryLocation () {
const list = (sortedLocations.value && sortedLocations.value.length) ? sortedLocations.value : locations.value
return (list || []).find(l => locHasSubs(l.name)) || (list || [])[0] || null
}
function openFindSlot () {
const loc = primaryLocation()
findSlotAddr.value = (loc && (loc.address_line || loc.address_full)) || ''
findSlotSkill.value = 'réparation' // appel client = réparation le plus souvent ; l'agent peut changer la compétence
findSlotOpen.value = true
}
function onFindSlotSelected (slot) {
const loc = primaryLocation()
createJobCtx.value = {
customer: customer.value?.name,
service_location: loc ? loc.name : '',
assigned_tech: slot.tech_id, scheduled_date: slot.date, start_time: slot.start_time,
address: slot._address || findSlotAddr.value || '', duration_h: slot._duration || 1,
latitude: slot._latitude, longitude: slot._longitude,
tags: slot._skill ? [slot._skill] : [],
}
createJobOpen.value = true
}
async function onJobCreated () { createJobOpen.value = false; await loadJobs(); $q.notify({ type: 'positive', icon: 'add_task', message: 'Intervention planifiée pour le client', timeout: 2600 }) }
// Journal ERPNext : appels (Communication medium=Phone) + messages loggés réintégrés à la fiche (ex-ChatterPanel).
const callLog = ref([])
async function loadCallLog () {

View File

@ -48,10 +48,17 @@
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
<!-- #2 Journal des changements + état auto-save (plus d'alerte « non publié ») -->
<q-btn dense flat round :icon="autosaving ? 'cloud_sync' : 'history'" :color="autosaving ? 'primary' : ''"><q-tooltip>Journal des changements · {{ autosaving ? 'sauvegarde en cours…' : 'auto-sauvegardé' }}</q-tooltip>
<q-menu anchor="bottom middle" self="top middle"><q-list dense style="min-width:250px;max-width:340px">
<q-menu anchor="bottom middle" self="top middle"><q-list dense style="min-width:290px;max-width:380px">
<q-item-label header class="q-py-xs">Journal <span :class="autosaving ? 'text-primary' : 'text-positive'">{{ autosaving ? 'sauvegarde…' : '✓ auto-sauvegardé' }}</span></q-item-label>
<q-item v-if="!changeLog.length"><q-item-section class="text-grey-6 text-caption">Aucun changement récent.</q-item-section></q-item>
<q-item v-for="(c, i) in changeLog.slice(0, 30)" :key="i" dense><q-item-section><q-item-label class="text-caption">{{ c.text }}</q-item-label></q-item-section><q-item-section side><span class="text-grey-5" style="font-size:10px">{{ new Date(c.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) }}</span></q-item-section></q-item>
<q-item v-for="c in changeLog.slice(0, 30)" :key="c.id" dense :class="{ 'chg-reverted': c.reverted }">
<q-item-section><q-item-label class="text-caption">{{ c.text }}</q-item-label></q-item-section>
<q-item-section side class="row items-center no-wrap q-gutter-xs">
<span class="text-grey-5" style="font-size:10px">{{ new Date(c.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) }}</span>
<q-badge v-if="c.reverted" color="grey-4" text-color="grey-7" label="annulé" />
<q-btn v-else-if="c.revert" flat dense round size="9px" icon="undo" color="primary" @click.stop="revertChange(c)"><q-tooltip>Annuler ce changement</q-tooltip></q-btn>
</q-item-section>
</q-item>
</q-list></q-menu>
</q-btn>
<q-separator vertical class="q-mx-xs" />
@ -456,7 +463,7 @@
<td class="tech-col">
<div class="tech-row">
<q-btn flat round dense size="9px" :icon="isRowSelected(ti) ? 'check_box' : 'check_box_outline_blank'" :color="isRowSelected(ti) ? 'primary' : 'grey-5'" @click.stop="maybeSelectRow(ti)"><q-tooltip>Sélectionner la rangée</q-tooltip></q-btn>
<q-btn flat round dense size="9px" :icon="isPaused(t) ? 'play_arrow' : 'pause'" :color="isPaused(t) ? 'grey' : 'primary'" @click.stop="togglePause(t)"><q-tooltip>{{ isPaused(t) ? 'Réactiver' : 'Pause' }}</q-tooltip></q-btn>
<q-btn flat round dense size="9px" icon="event" :color="isPaused(t) ? 'orange-8' : 'primary'" @click.stop="openTechSchedule(t)"><q-tooltip>Horaire de {{ t.name }} récurrent · pause · congés{{ isPaused(t) ? ' (en pause)' : '' }}</q-tooltip></q-btn>
<q-badge v-if="techRank(t)" color="teal">{{ techRank(t) }}<q-tooltip>Priorité {{ techRank(t) }} combine maîtrise (niveau) + vitesse (efficacité) + coût.</q-tooltip></q-badge>
<!-- Clic sur la RESSOURCE (nom ou chips) propriétés de l'employé (compétences, horaire) -->
<span class="tech-name clk" @click.stop="openSkillEditor(t, $event)"><q-tooltip>Réglages de {{ t.name }} compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée</q-tooltip>{{ t.name }}</span>
@ -1911,6 +1918,7 @@
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" @edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" />
</q-page>
</template>
@ -1964,6 +1972,7 @@ import QuoteWizard from 'src/components/shared/QuoteWizard.vue' // soumission :
import { useCreateSignal } from 'src/composables/useCreateSignal' // FAB global « Créer » ouvre le chooser ici
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' // moteur soumission existant (panier + rabais + devis + acceptation) réutilisé
import WeeklyScheduleEditor from 'src/components/shared/WeeklyScheduleEditor.vue' // génération de quarts hebdo par tech (modèles) repris/amélioré depuis Dispatch
import TechScheduleDialog from 'src/components/planif/TechScheduleDialog.vue' // écran unique horaire/pause/congés par tech (icône « event » de la rangée)
const $q = useQuasar()
const router = useRouter()
@ -2731,6 +2740,17 @@ 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 })
}
// Écran unique « Horaire » par tech (icône event de la rangée) : pause indéfinie + horaire récurrent + calendrier congés + archivage.
const techSchedOpen = ref(false); const techSchedTech = ref(null)
function openTechSchedule (t) { techSchedTech.value = t; techSchedOpen.value = true }
function onTechSchedEdit (t) { techSchedOpen.value = false; openSchedGen(t) } // « Modifier l'horaire récurrent » réutilise WeeklyScheduleEditor existant
async function onTechSchedChanged (ev) {
// pause : reflète le statut localement ; archivage : recharge la base (le tech disparaît). Congés : recharge la semaine (hachures).
if (ev && ev.status) { const tt = techs.value.find(x => x.id === ev.id); if (tt) tt.status = ev.status }
if (ev && ev.archived) { await loadBase() }
await loadWeek()
}
// Génération de quarts HEBDO par tech (modèles + N semaines) écrit DIRECTEMENT les Shift Assignment (Publié).
const schedGenOpen = ref(false); const schedGenTechs = ref([])
function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech
@ -5939,9 +5959,23 @@ function onEnter (ti, di) { if (!drag.on) return; drag.moved = true; selection.v
function onUp () { if (drag.on) { drag.on = false; if (drag.moved) justDragged.value = true } }
// #2 AUTO-SAVE : chaque édition de la grille est persistée en BROUILLON (statut 'Proposé') après un court debounce. Plus rien n'est « non sauvegardé ».
const autosaving = ref(false)
const changeLog = ref([]) // journal de session : { at, text }
const changeLog = ref([]) // journal de session : { id, at, text, revert?:{tech,date,before}, reverted? }
let _draftT = null
function logChange (text) { changeLog.value.unshift({ at: Date.now(), text }); if (changeLog.value.length > 60) changeLog.value.pop() }
let _chgSeq = 0
// revert (optionnel) = état AVANT d'une cellule (tech|date) permet d'annuler CE changement précis depuis le journal (icône history), à la façon de la liste « Publier ».
function logChange (text, revert) { changeLog.value.unshift({ id: ++_chgSeq, at: Date.now(), text, revert: revert || null, reverted: false }); if (changeLog.value.length > 60) changeLog.value.pop() }
// Annule UN changement du journal : restaure la cellule (tech, date) à son état d'avant. L'annulation est elle-même dans l'historique global (undo).
function revertChange (c) {
if (!c || !c.revert || c.reverted) return
const { tech, date, before } = c.revert
pushHistory()
const others = assignments.value.filter(a => !(a.tech === tech && a.date === date))
assignments.value = [...others, ...JSON.parse(JSON.stringify(before || []))]
c.reverted = true
logChange('↶ Annulé · ' + c.text)
$q.notify({ type: 'info', message: 'Changement annulé', timeout: 1600 })
scheduleDraftSave()
}
function scheduleDraftSave () { if (_draftT) clearTimeout(_draftT); _draftT = setTimeout(autosaveDraft, 800) }
async function autosaveDraft () {
if (_draftT) { clearTimeout(_draftT); _draftT = null }
@ -5949,10 +5983,11 @@ async function autosaveDraft () {
autosaving.value = true
try { await roster.publishWeek(start.value, days.value, assignments.value, false, 'draft') } catch (e) { /* non bloquant : réessaie au prochain edit */ } finally { autosaving.value = false }
}
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }]; logChange('Quart ajouté · ' + techName + ' · ' + iso); scheduleDraftSave() }
function setCellReplace (techId, techName, iso, tpl) { const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept; logChange('Quart défini · ' + techName + ' · ' + iso); scheduleDraftSave() }
function cellSnap (techId, iso) { return JSON.parse(JSON.stringify(cellsOf(techId, iso))) } // état AVANT d'une cellule payload de revert du journal
function addShift (techId, techName, iso, tpl) { if (cellsOf(techId, iso).some(a => a.shift === tpl.name)) return; const before = cellSnap(techId, iso); assignments.value = [...assignments.value, { tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }]; logChange('Quart ajouté · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
function setCellReplace (techId, techName, iso, tpl) { const before = cellSnap(techId, iso); const kept = assignments.value.filter(a => !(a.tech === techId && a.date === iso)); kept.push({ tech: techId, tech_name: techName, date: iso, shift: tpl.name, shift_name: tpl.template_name, zone: tpl.zone || '', hours: tpl.hours || 8, status: 'Proposé', source: 'manuel', color: tpl.color }); assignments.value = kept; logChange('Quart défini · ' + techName + ' · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
function removeShift (techId, iso, shift) { assignments.value = assignments.value.filter(a => !(a.tech === techId && a.date === iso && a.shift === shift)) }
function clearLocal (techId, iso) { assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)); logChange('Quart retiré · ' + iso); scheduleDraftSave() }
function clearLocal (techId, iso) { const before = cellSnap(techId, iso); assignments.value = assignments.value.filter(x => !(x.tech === techId && x.date === iso)); logChange('Quart retiré · ' + iso, { tech: techId, date: iso, before }); scheduleDraftSave() }
// Ouvre le menu d'horaire (Jour/Soir/Garde/Absent + heures) ancré sur la cellule.
function openShiftMenu (t, d, ev, ti, di) {
selection.value = []; anchor.value = { ti, di }; menu.tech = t; menu.day = d
@ -6493,6 +6528,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
.leg-absent { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; border: 1px solid #b0b0b0; background: repeating-linear-gradient(45deg,#cfcfcf 0,#cfcfcf 3px,#f0f0f0 3px,#f0f0f0 6px); }
.leg-garde { display: inline-block; width: 24px; height: 9px; border-radius: 2px; vertical-align: middle; background: rgba(255,179,0,.14); border: 1px dashed #f9a825; }
tr.paused .tech-col { color: #aaa; }
.chg-reverted .text-caption { text-decoration: line-through; color: #9ca3af; }
tfoot .sum td { background: #fafafa; font-size: 11px; color: #555; font-weight: 600; }
tfoot .sum .tech-col { background: #fafafa; }
.eff { font-size: 9px; border-radius: 3px; padding: 0 4px; margin-left: 3px; font-weight: 600; }

View File

@ -161,7 +161,7 @@
</template>
</DetailModal>
<InterveneDialog v-model="intervene.open" :name="intervene.name" :title="intervene.title" doctype="Issue" />
<InterveneDialog v-model="intervene.open" :name="intervene.name" :title="intervene.title" doctype="Issue" @followed="onIntervenedFollow" />
<AddToTaskDialog v-model="addTaskOpen" :source="taskSource" />
</q-page>
</template>
@ -352,6 +352,11 @@ async function toggleFollow (name) {
myFollows.value = following ? myFollows.value.filter(n => n !== name) : [...myFollows.value, name]
} catch (e) { /* suivi best-effort */ }
}
// Le dialogue « Intervenir » peut aussi (dé)suivre garder l'étoile de l'en-tête synchronisée.
function onIntervenedFollow ({ name, doctype, following }) {
if (doctype !== 'Issue' || !name) return
myFollows.value = following ? [...new Set([...myFollows.value, name])] : myFollows.value.filter(n => n !== name)
}
onMounted(async () => {
await Promise.all([loadIssueTypes(), loadMyDepartments(), loadMyFollows()])

View File

@ -501,6 +501,23 @@ function loadQueueMembers () { return readJsonFile('/app/data/queue_members.json
function loadVisibility () { return readJsonFile('/app/data/queue_visibility.json', {}) }
// Suivi de tickets PAR PERSONNE (watch perso) : { email: [ticketName,...] } → toujours visibles dans « Mes tickets ».
function loadTicketFollows () { return readJsonFile('/app/data/ticket_follows.json', {}) }
// Suivi GÉNÉRALISÉ (au-delà des tickets) : { email: { doctype: [name,...] } }. Migre une seule fois l'ancien ticket_follows.json sous « Issue ».
function loadFollows () {
const all = readJsonFile('/app/data/follows.json', null)
if (all) return all
const legacy = loadTicketFollows() // { email: [ticketName] }
const migrated = {}
for (const [email, arr] of Object.entries(legacy || {})) migrated[email] = { Issue: Array.isArray(arr) ? arr.slice() : [] }
writeJsonFile('/app/data/follows.json', migrated)
return migrated
}
function saveFollows (all) {
writeJsonFile('/app/data/follows.json', all)
// Miroir rétro-compatible : garde ticket_follows.json à jour (schéma email → [ticketName]) pour tout lecteur legacy.
const tf = {}; for (const [email, byType] of Object.entries(all || {})) tf[email] = (byType && byType.Issue) ? byType.Issue.slice() : []
writeJsonFile('/app/data/ticket_follows.json', tf)
}
function followsFor (all, email, doctype) { return (email && all[email] && all[email][doctype]) || [] }
// Règles d'inbox définies par l'agent (« Filtrer comme ceci ») : [{ id, field:'from'|'subject', contains, action:'mask'|'allow' }]. « allow » prime sur « mask ».
function loadInboxRules () { return readJsonFile('/app/data/inbox_rules.json', []) }
function matchInboxRule (email, subject) {
@ -1268,17 +1285,37 @@ async function handle (req, res, method, p, url) {
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Suivi PERSO d'un ticket (watch) : GET → mes tickets suivis ; POST {ticket, follow} → (dé)suivre. Toujours visibles dans « Mes tickets ».
// Rétro-compatible : délègue au store généralisé (doctype « Issue »).
if (p === '/conversations/ticket-follow' && (method === 'GET' || method === 'POST')) {
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
const all = loadTicketFollows()
if (method === 'GET') return json(res, 200, { follows: (email && all[email]) || [] })
const all = loadFollows()
if (method === 'GET') return json(res, 200, { follows: followsFor(all, email, 'Issue') })
try {
const b = await parseBody(req); const t = String(b.ticket || '').trim()
if (!email || !t) return json(res, 400, { error: 'email + ticket requis' })
const arr = new Set(all[email] || [])
const arr = new Set(followsFor(all, email, 'Issue'))
if (b.follow === false) arr.delete(t); else arr.add(t)
all[email] = [...arr]; writeJsonFile('/app/data/ticket_follows.json', all)
return json(res, 200, { ok: true, follows: all[email], following: arr.has(t) })
;(all[email] || (all[email] = {})).Issue = [...arr]; saveFollows(all)
return json(res, 200, { ok: true, follows: all[email].Issue, following: arr.has(t) })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Suivi GÉNÉRALISÉ (tout doctype) : GET ?doctype=X → mes noms suivis ; POST {doctype, name, follow} → (dé)suivre.
// Ex. suivre un Dispatch Job, un Customer… au-delà des tickets (Issue). Le suivi « Issue » reste synchronisé avec ticket-follow.
if (p === '/conversations/follow' && (method === 'GET' || method === 'POST')) {
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
const all = loadFollows()
if (method === 'GET') {
const dt = String(url.searchParams.get('doctype') || '').trim()
if (dt) return json(res, 200, { doctype: dt, follows: followsFor(all, email, dt) })
return json(res, 200, { follows: (email && all[email]) || {} }) // tous doctypes → { doctype: [names] }
}
try {
const b = await parseBody(req); const dt = String(b.doctype || '').trim(); const nm = String(b.name || '').trim()
if (!email || !dt || !nm) return json(res, 400, { error: 'email + doctype + name requis' })
const arr = new Set(followsFor(all, email, dt))
if (b.follow === false) arr.delete(nm); else arr.add(nm)
;(all[email] || (all[email] = {}))[dt] = [...arr]; saveFollows(all)
return json(res, 200, { ok: true, doctype: dt, follows: all[email][dt], following: arr.has(nm) })
} catch (e) { return json(res, 500, { error: e.message }) }
}
// Règles d'inbox (« Filtrer comme ceci ») : masquer/afficher par expéditeur ou sujet — l'agent choisit ce qui est masqué.

View File

@ -141,7 +141,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
const wantSkill = String(skill || '').trim().toLowerCase()
const isRepair = /r[ée]par|d[ée]pann|panne|bris/.test(wantSkill) // les WEEK-ENDS sont RÉSERVÉS aux réparations → sautés pour les autres compétences
const [techRes, jobRes, tplRes, shiftRes] = await Promise.all([
const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
'name', 'technician_id', 'full_name', 'status', 'longitude', 'latitude',
'absence_from', 'absence_until', 'skills', '_user_tags',
@ -156,11 +156,20 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
]))}&limit_page_length=500`),
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
// Congés/absences APPROUVÉS (Tech Availability) — où vivent les vacances (import calendrier, dialogue congés, grille). Sinon un tech en congé mais avec un quart matérialisé ressort quand même.
erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
])
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
// FILTRE COMPÉTENCE : ne garder que les techs qui ONT le skill demandé (champ skills ou tags Frappe).
const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && hasSkill(t))
const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'Archivé' && t.status !== 'En pause' && hasSkill(t))
// Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability.
const vacBy = {}
for (const a of ((availRes.data && availRes.data.data) || [])) {
const set = vacBy[a.technician] || (vacBy[a.technician] = new Set())
for (const d of dates) if (d >= a.from_date && d <= a.to_date) set.add(d)
}
const vacDays = (t) => vacBy[t.technician_id] || vacBy[t.name] || null
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
// Fenêtres de quart RÉELLES par tech×jour (Shift Assignment × Shift Template) — plus de fenêtre 8-17 par défaut : un tech SANS quart ce jour = pas de créneau.
const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t
@ -179,12 +188,14 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
for (const tech of techs) {
const homeCoords = tech.longitude && tech.latitude
? [parseFloat(tech.longitude), parseFloat(tech.latitude)] : null
const techVac = vacDays(tech) // jours de congé approuvé (Tech Availability)
for (const dateStr of dates) {
// WEEK-END réservé aux réparations : on saute sam/dim pour toute autre compétence.
const dow = new Date(dateStr + 'T12:00:00').getUTCDay()
if ((dow === 0 || dow === 6) && !isRepair) continue
// Absence window skip.
// Congé approuvé (Tech Availability) OU fenêtre d'absence du Dispatch Technician → pas de créneau.
if (techVac && techVac.has(dateStr)) continue
if (tech.absence_from && tech.absence_until &&
dateStr >= tech.absence_from && dateStr <= tech.absence_until) continue
// QUART RÉEL du tech ce jour (Shift Assignment) — pas de quart = pas de créneau (créer un quart pour en ouvrir).
@ -284,6 +295,91 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
return picked
}
// Occupation par ressource (tech) sur l'horizon, FILTRÉE par compétence — même modèle de données que suggestSlots
// (quart réel = capacité, jobs planifiés = occupé), mais renvoie l'OCCUPATION plutôt que les trous. Alimente la
// visualisation « bandes verticales » de « Trouver un créneau » : on voit d'un coup qui a de la place pour le skill choisi.
async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' } = {}) {
const baseDate = after_date || todayET()
const span = Math.max(1, Math.min(21, parseInt(days, 10) || SLOT_HORIZON_DAYS))
const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
const wantSkill = String(skill || '').trim().toLowerCase()
const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
'name', 'technician_id', 'full_name', 'status', 'absence_from', 'absence_until', 'skills', '_user_tags',
]))}&limit_page_length=50`),
erpFetch(`/api/resource/Dispatch Job?filters=${encodeURIComponent(JSON.stringify([
['status', 'in', ['open', 'assigned']],
['scheduled_date', '>=', dates[0]],
['scheduled_date', '<=', dates[dates.length - 1]],
]))}&fields=${encodeURIComponent(JSON.stringify([
'name', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'job_type',
]))}&limit_page_length=500`),
erpFetch(`/api/resource/Shift Template?fields=${encodeURIComponent(JSON.stringify(['name', 'start_time', 'end_time', 'on_call']))}&limit_page_length=100`),
erpFetch(`/api/resource/Shift Assignment?filters=${encodeURIComponent(JSON.stringify([['assignment_date', 'in', dates]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'assignment_date', 'shift_template']))}&limit_page_length=2000`),
erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
])
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
const techs = (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'Archivé' && t.status !== 'En pause' && hasSkill(t))
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
const vacBy = {}
for (const a of ((availRes.data && availRes.data.data) || [])) {
const set = vacBy[a.technician] || (vacBy[a.technician] = new Set())
for (const d of dates) if (d >= a.from_date && d <= a.to_date) set.add(d)
}
const tpl = {}; for (const t of ((tplRes.data && tplRes.data.data) || [])) tpl[t.name] = t
const winBy = {} // 'tech|date' → { start_h, end_h } (quart réel)
for (const a of ((shiftRes.data && shiftRes.data.data) || [])) {
const tp = tpl[a.shift_template]; if (!tp || tp.on_call) continue
const s = timeToHours(tp.start_time), e = timeToHours(tp.end_time); if (s == null || e == null || !(e > s)) continue
const k = a.technician + '|' + a.assignment_date
const w = winBy[k]; winBy[k] = w ? { start_h: Math.min(w.start_h, s), end_h: Math.max(w.end_h, e) } : { start_h: s, end_h: e }
}
const out = techs.map(tech => {
const techVac = vacBy[tech.technician_id] || vacBy[tech.name] || null
const cells = dates.map(dateStr => {
const dow = new Date(dateStr + 'T12:00:00').getUTCDay()
const onLeave = techVac && techVac.has(dateStr)
const absent = tech.absence_from && tech.absence_until && dateStr >= tech.absence_from && dateStr <= tech.absence_until
const shift = winBy[tech.technician_id + '|' + dateStr] || winBy[tech.name + '|' + dateStr]
if (onLeave) return { date: dateStr, dow, off: true, reason: 'vacation', occupancy: null }
if (absent) return { date: dateStr, dow, off: true, reason: 'absence', occupancy: null }
if (!shift) return { date: dateStr, dow, off: true, reason: (dow === 0 || dow === 6) ? 'weekend' : 'no_shift', occupancy: null }
const shiftH = shift.end_h - shift.start_h
const dayJobs = allJobs
.filter(j => j.assigned_tech === tech.technician_id && j.scheduled_date === dateStr && j.start_time)
.map(j => { const s = timeToHours(j.start_time); return { start_h: s, end_h: s + (parseFloat(j.duration_h) || 1), reserved: j.job_type === 'Réservation' } })
.sort((a, b) => a.start_h - b.start_h)
const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
const busyH = dayJobs.reduce((acc, j) => acc + clamp(j), 0)
const blocks = dayJobs.map(j => ({
start: hoursToTime(Math.max(j.start_h, shift.start_h)), end: hoursToTime(Math.min(j.end_h, shift.end_h)),
top: shiftH > 0 ? +Math.max(0, (Math.max(j.start_h, shift.start_h) - shift.start_h) / shiftH).toFixed(3) : 0,
height: shiftH > 0 ? +Math.max(0, clamp(j) / shiftH).toFixed(3) : 0,
reserved: j.reserved,
}))
return {
date: dateStr, dow, off: false, shift_start: hoursToTime(shift.start_h), shift_end: hoursToTime(shift.end_h),
shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1), jobs: dayJobs.length,
occupancy: shiftH > 0 ? Math.min(1, +(busyH / shiftH).toFixed(2)) : 0, blocks,
}
})
const totShift = cells.reduce((a, c) => a + (c.shift_h || 0), 0)
const totBusy = cells.reduce((a, c) => a + (c.busy_h || 0), 0)
return {
tech_id: tech.technician_id, tech_name: tech.full_name,
shift_h: +totShift.toFixed(1), busy_h: +totBusy.toFixed(1), free_h: +Math.max(0, totShift - totBusy).toFixed(1),
occupancy: totShift > 0 ? Math.min(1, +(totBusy / totShift).toFixed(2)) : null, days: cells,
}
})
// Le moins occupé d'abord = meilleurs candidats ; les techs sans aucun quart sur l'horizon en dernier.
out.sort((a, b) => (a.occupancy == null ? 2 : a.occupancy) - (b.occupancy == null ? 2 : b.occupancy) || String(a.tech_name).localeCompare(b.tech_name))
return { start: baseDate, days: span, dates, skill: wantSkill, techs: out }
}
// Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id).
// Best-effort (max du jour + 1) ; le VRAI garde-fou anti-collision = l'unicité du nom à l'insertion (le créateur réessaie au besoin).
async function nextJobRef () {
@ -742,6 +838,17 @@ async function handle (req, res, method, path) {
}
}
// POST /dispatch/occupancy — occupation par ressource (tech) sur l'horizon, filtrée par compétence → bandes « Trouver un créneau »
if (sub === 'occupancy' && method === 'POST') {
try {
const body = await parseBody(req)
return json(res, 200, await techOccupancy(body))
} catch (e) {
log('occupancy error:', e.message)
return json(res, 500, { error: e.message })
}
}
// GET /dispatch/group-jobs?group=Tech+Targo&exclude_tech=TECH-001
// List unassigned jobs that a tech can self-claim. The tech PWA uses this
// to render the "Tâches du groupe" subscription feed.
@ -949,4 +1056,4 @@ async function agentCreateDispatchJob ({ customer_id, service_location, subject,
}
}
module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely, nextJobRef, createInstallChain }
module.exports = { handle, agentCreateDispatchJob, rankTechs, getTechsWithLoad, enrichWithGps, suggestSlots, techOccupancy, unblockDependents, setJobStatusWithChain, activateSubscriptionForJob, deleteJobSafely, nextJobRef, createInstallChain }

View File

@ -72,6 +72,7 @@ function setJobRemote (name, remote) {
const SOLVER_URL = cfg.ROSTER_SOLVER_URL || 'http://roster-solver:8090'
const PAUSE_STATUS = 'En pause'
const AVAIL_STATUS = 'Disponible'
const ARCHIVE_STATUS = 'Archivé' // tech archivé (réversible) : masqué du dispatch/solveur/créneaux, historique conservé
// ── Date helpers (local, sans dépendance) ──────────────────────────────────
function iso (d) { return d.toISOString().slice(0, 10) }
@ -356,7 +357,15 @@ async function _fetchTechniciansRaw () {
absence_from: t.absence_from,
absence_until: t.absence_until,
weekly_schedule: (() => { try { return JSON.parse(t.weekly_schedule || 'null') } catch { return null } })(), // patron récurrent {mon:{start,end}|null,…} — source des quarts matérialisés (hybride)
}))
})).filter(t => t.status !== ARCHIVE_STATUS) // techs archivés = masqués partout (planif, solveur, stats) ; restaurables via /roster/technicians?archived=1
}
// Techs ARCHIVÉS uniquement (pour l'UI de restauration) — requête directe (hors cache).
async function fetchArchivedTechnicians () {
const rows = await erp.list('Dispatch Technician', {
filters: [['resource_type', '=', 'human'], ['status', '=', ARCHIVE_STATUS]],
fields: ['name', 'technician_id', 'full_name', 'status', 'absence_reason'], limit: 500,
})
return rows.map(t => ({ id: t.technician_id || t.name, name: t.full_name || t.technician_id, status: t.status, reason: t.absence_reason || '' }))
}
// Construit, pour chaque tech, la liste des dates indisponibles dans l'horizon.
@ -1266,6 +1275,10 @@ async function handle (req, res, method, path, url) {
const days = parseInt(qs.get('days') || '7', 10)
if (path === '/roster/technicians' && method === 'GET') {
if (url && url.searchParams && url.searchParams.get('archived') === '1') {
const arch = await fetchArchivedTechnicians()
return json(res, 200, { technicians: arch, count: arch.length, archived: true })
}
const techs = await fetchTechnicians()
return json(res, 200, { technicians: techs, count: techs.length })
}
@ -2025,6 +2038,17 @@ async function handle (req, res, method, path, url) {
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status })
}
// Archiver / restaurer un technicien (réversible) : status Archivé ↔ Disponible. Masqué partout mais historique conservé.
const mArchive = path.match(/^\/roster\/technician\/(.+)\/archive$/)
if (mArchive && method === 'POST') {
const techId = decodeURIComponent(mArchive[1])
const b = await parseBody(req)
const techName = await resolveTechName(techId)
if (!techName) return json(res, 404, { error: 'technicien introuvable: ' + techId })
const patch = { status: b.archived ? ARCHIVE_STATUS : AVAIL_STATUS }
const r = await retryWrite(() => erp.update('Dispatch Technician', techName, patch)); if (r && r.ok) invalidateTechCache()
return json(res, r.ok ? 200 : 500, { ...r, technician: techId, status: patch.status, archived: !!b.archived })
}
// Positions GPS LIVE de TOUS les techs ayant un appareil Traccar → marqueurs « live » sur la carte des tournées
// (rafraîchi périodiquement côté client). Léger : une requête position par appareil (getPositions parallèle).
if (path === '/roster/tech-positions' && method === 'GET') {