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:
parent
77343d0a98
commit
13039be301
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
271
apps/ops/src/components/planif/TechScheduleDialog.vue
Normal file
271
apps/ops/src/components/planif/TechScheduleDialog.vue
Normal 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>
|
||||
|
|
@ -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(/</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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
|
|
|
|||
|
|
@ -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é.
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user