diff --git a/apps/ops/src/api/dispatch.js b/apps/ops/src/api/dispatch.js index 52ddfee..70007ef 100644 --- a/apps/ops/src/api/dispatch.js +++ b/apps/ops/src/api/dispatch.js @@ -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() diff --git a/apps/ops/src/api/roster.js b/apps/ops/src/api/roster.js index 81a072f..3117bc7 100644 --- a/apps/ops/src/api/roster.js +++ b/apps/ops/src/api/roster.js @@ -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) } diff --git a/apps/ops/src/components/planif/TechScheduleDialog.vue b/apps/ops/src/components/planif/TechScheduleDialog.vue new file mode 100644 index 0000000..ebb2a43 --- /dev/null +++ b/apps/ops/src/components/planif/TechScheduleDialog.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/apps/ops/src/components/shared/InterveneDialog.vue b/apps/ops/src/components/shared/InterveneDialog.vue index 34e976b..0d4a14b 100644 --- a/apps/ops/src/components/shared/InterveneDialog.vue +++ b/apps/ops/src/components/shared/InterveneDialog.vue @@ -15,11 +15,13 @@
-
- -
{{ a.label }}
-
{{ a.hint }}
+
+ +
{{ a.key === 'follow' ? (following ? 'Suivi' : 'Suivre') : a.label }}
+
{{ a.key === 'follow' && following ? 'Tu reçois les suites' : a.hint }}
+
@@ -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(/</g, '<').replace(/&/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; } diff --git a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue index 6b14391..8daee68 100644 --- a/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue +++ b/apps/ops/src/modules/dispatch/components/SuggestSlotsDialog.vue @@ -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 }}
+ + +
+
+
Aucune ressource{{ skill ? ' pour « ' + skill + ' »' : '' }} sur l'horizon.
+ +
+
+ @@ -127,4 +204,27 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge diff --git a/apps/ops/src/pages/ClientDetailPage.vue b/apps/ops/src/pages/ClientDetailPage.vue index 4fa31c3..982bdd7 100644 --- a/apps/ops/src/pages/ClientDetailPage.vue +++ b/apps/ops/src/pages/ClientDetailPage.vue @@ -24,6 +24,9 @@ + + Prendre RDV (réparation / installation) — pré-rempli avec l'adresse du client + Carte-cadeau : voir / copier le lien, éditer ou envoyer par courriel @@ -587,6 +590,12 @@ :context="ticketContext" :locations="locations" @created="onTicketCreated" /> + + + + 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 () { diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index 1707b88..f3a2ab6 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -48,10 +48,17 @@ Rétablir (Ctrl+Shift+Z) Journal des changements · {{ autosaving ? 'sauvegarde en cours…' : 'auto-sauvegardé' }} - + Journal — {{ autosaving ? 'sauvegarde…' : '✓ auto-sauvegardé' }} Aucun changement récent. - {{ c.text }}{{ new Date(c.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) }} + + {{ c.text }} + + {{ new Date(c.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit' }) }} + + Annuler ce changement + + @@ -456,7 +463,7 @@
Sélectionner la rangée - {{ isPaused(t) ? 'Réactiver' : 'Pause' }} + Horaire de {{ t.name }} — récurrent · pause · congés{{ isPaused(t) ? ' (en pause)' : '' }} {{ techRank(t) }}Priorité {{ techRank(t) }} — combine maîtrise (niveau) + vitesse (efficacité) + coût. Réglages de {{ t.name }} — compétences · cadence · horaire · domicile · appareil GPS · voir sa tournée{{ t.name }} @@ -1911,6 +1918,7 @@ + @@ -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; } diff --git a/apps/ops/src/pages/TicketsPage.vue b/apps/ops/src/pages/TicketsPage.vue index 9eb12fd..a30cd6b 100644 --- a/apps/ops/src/pages/TicketsPage.vue +++ b/apps/ops/src/pages/TicketsPage.vue @@ -161,7 +161,7 @@ - + @@ -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()]) diff --git a/services/targo-hub/lib/conversation.js b/services/targo-hub/lib/conversation.js index 1bea27c..3b09234 100644 --- a/services/targo-hub/lib/conversation.js +++ b/services/targo-hub/lib/conversation.js @@ -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é. diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js index baea30e..6523f2f 100644 --- a/services/targo-hub/lib/dispatch.js +++ b/services/targo-hub/lib/dispatch.js @@ -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 } diff --git a/services/targo-hub/lib/roster.js b/services/targo-hub/lib/roster.js index 94be724..16fb3e2 100644 --- a/services/targo-hub/lib/roster.js +++ b/services/targo-hub/lib/roster.js @@ -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') {