diff --git a/apps/ops/src/components/shared/AvailabilityByReason.vue b/apps/ops/src/components/shared/AvailabilityByReason.vue index 72023a1..8ca7f24 100644 --- a/apps/ops/src/components/shared/AvailabilityByReason.vue +++ b/apps/ops/src/components/shared/AvailabilityByReason.vue @@ -18,6 +18,7 @@ import { Notify } from 'quasar' import { DEPARTMENTS, suggestDepartments } from 'src/config/departments' import { resolveSkills } from 'src/api/roster' import OccupancyBands from 'src/components/shared/OccupancyBands.vue' +import { skillSym } from 'src/composables/useSkillIcons' // icônes de compétences (SOURCE UNIQUE, partagée avec Planif) const props = defineProps({ modelValue: { type: Boolean, default: false }, @@ -27,6 +28,7 @@ const props = defineProps({ address: { type: String, default: '' }, lat: { type: Number, default: null }, lng: { type: Number, default: null }, + connectionType: { type: String, default: '' }, // type de connexion du lieu (Fibre/Wireless/LTE…) → injecte « sans-fil » si sans-fil }) const emit = defineEmits(['update:modelValue', 'book']) @@ -38,13 +40,25 @@ const resolved = ref(null) // { skills, primary, confidence, source, reason } const aiLoading = ref(false) const occ = ref(null) // dernier chargement des bandes → résumé du dénominateur -// Compétence(s) effective(s) : résolveur si présent, sinon skills du chip choisi. -const skills = computed(() => { - if (resolved.value && resolved.value.skills) return resolved.value.skills - const d = DEPARTMENTS.find(x => x.category === dept.value) - return d ? (d.skills || []) : [] -}) +// Le client est-il en technologie SANS-FIL (vs fibre) ? → toute intervention terrain exige aussi « sans-fil ». +const wireless = computed(() => /wireless|\blte\b|sans.?fil|\bfwa\b|radio|antenne/i.test(String(props.connectionType || ''))) + +// Compétence(s) effective(s) — ÉDITABLE : dérivée du résolveur/chip (+ « sans-fil » si client sans-fil), +// mais l'agent peut RETIRER une compétence ajoutée à tort (ex. « sans-fil » sur un souci non lié au radio). +const skills = ref([]) +function deriveSkills () { + const base = (resolved.value && resolved.value.skills) + ? [...resolved.value.skills] + : (() => { const d = DEPARTMENTS.find(x => x.category === dept.value); return d ? [...(d.skills || [])] : [] })() + // Injecté seulement s'il y a DÉJÀ une compétence terrain (une demande traitée à distance ne devient pas « sans-fil »). + if (wireless.value && base.length && !base.some(s => /sans.?fil|wireless/i.test(s))) base.push('sans-fil') + skills.value = base +} +// Re-dérive quand le motif/la résolution/le type de connexion change (réinitialise donc les retraits manuels — voulu). +watch([resolved, dept, wireless], deriveSkills, { immediate: true }) +function removeSkill (s) { skills.value = skills.value.filter(x => x !== s) } const primarySkill = computed(() => skills.value[0] || '') +const isWirelessSkill = (s) => /sans.?fil|wireless/i.test(String(s || '')) const durH = computed(() => { const d = DEPARTMENTS.find(x => x.category === dept.value); return d ? (d.dur || 1) : 1 }) // Résumé du DÉNOMINATEUR ajusté (agrégé depuis les bandes) : heures libres + nb de techs qualifiés. @@ -137,7 +151,13 @@ function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge Compétence requise : - {{ s }} + + {{ s }} + Ajoutée automatiquement (client sans-fil {{ connectionType }}) — ✕ pour retirer si non lié + ✕ pour retirer cette compétence si non requise + Aucune compétence terrain — traité à distance (tous les agents). {{ confidenceLabel[resolved.confidence] || resolved.confidence }} @@ -153,10 +173,10 @@ function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
{{ summary.free_h }} h libres
-
{{ summary.techs }} tech{{ summary.techs > 1 ? 's' : '' }} qualifié{{ summary.techs > 1 ? 's' : '' }}{{ primarySkill ? ' « ' + primarySkill + ' »' : '' }} · {{ summary.shift_h }} h de quart
+
{{ summary.techs }} tech{{ summary.techs > 1 ? 's' : '' }} qualifié{{ summary.techs > 1 ? 's' : '' }}{{ skills.length ? ' « ' + skills.join(' + ') + ' »' : '' }} · {{ summary.shift_h }} h de quart
- +
Choisis un motif ou dicte la raison pour voir la disponibilité ajustée à la compétence.
diff --git a/apps/ops/src/components/shared/OccupancyBands.vue b/apps/ops/src/components/shared/OccupancyBands.vue index f8701ac..981e791 100644 --- a/apps/ops/src/components/shared/OccupancyBands.vue +++ b/apps/ops/src/components/shared/OccupancyBands.vue @@ -75,9 +75,9 @@ defineExpose({ reload: load })
{{ occShort(c.date) }} · {{ offLabel(c) }}
-
- {{ occShort(c.date) }} · {{ c.shift_start }}–{{ c.shift_end }}
{{ pct(c.occupancy) }} occupé · {{ c.free_h }} h libre · {{ c.jobs }} job{{ c.jobs > 1 ? 's' : '' }}
+ {{ occShort(c.date) }} · {{ c.shift_start }}–{{ c.shift_end }}
{{ pct(c.occupancy) }} occupé · {{ c.free_h }} h libre · {{ c.jobs }} job{{ c.jobs > 1 ? 's' : '' }}
⚠ {{ c.untimed_jobs }} sans heure fixée
@@ -108,6 +108,8 @@ defineExpose({ reload: load }) .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; } +/* Jobs assignés SANS heure fixée (legacy) : occupent la journée mais heure non planifiée → hachuré. */ +.occ-block.untimed { background: repeating-linear-gradient(45deg, #2563eb, #2563eb 3px, #60a5fa 3px, #60a5fa 6px); opacity: .85; } .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; } diff --git a/apps/ops/src/config/departments.js b/apps/ops/src/config/departments.js index db91433..f9c4a75 100644 --- a/apps/ops/src/config/departments.js +++ b/apps/ops/src/config/departments.js @@ -14,7 +14,7 @@ * Éditer ici pour ajuster les mots-clés / compétences — une seule source. */ export const DEPARTMENTS = [ - { category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary', skills: [], dur: 1, + { category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary', skills: ['support'], dur: 1, re: /\b(wi-?fi|internet|lent[e]?|ralenti|panne|coup[ée]+|connexion|connecter?|signal|modem|routeur|red[ée]marr|d[ée]connect|d[ée]branch|intermittent|latence|ping|ne marche|ne fonctionne|pas d['e]internet|hors service|bug)\b/i }, { category: 'Facturation', queue: 'Facturations', icon: 'receipt_long', color: 'green-7', skills: [], dur: 1, re: /\b(factur|paiement|payer|pay[ée]|solde|rembours|pr[ée]l[èe]v|carte de cr[ée]dit|montant|frais|cr[ée]dit|impay[ée]|retard de paiement|re[çc]u|virement|interac|trop per[çc]u)\b/i }, diff --git a/services/targo-hub/lib/dispatch.js b/services/targo-hub/lib/dispatch.js index b67cc98..c07cd00 100644 --- a/services/targo-hub/lib/dispatch.js +++ b/services/targo-hub/lib/dispatch.js @@ -3,7 +3,7 @@ const cfg = require('./config') const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers') // Prédicat « ce tech a-t-il la compétence ? » PARTAGÉ (même sémantique que capacityByDay/solveur). Le champ ERP `skills` // (ou `_user_tags`) est une CSV → techHasSkill l'accepte telle quelle. SOURCE UNIQUE : lib/skill-resolver.js. -const { techHasSkill } = require('./skill-resolver') +const { techHasSkill, techHasSkills } = require('./skill-resolver') // Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement). const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String)) @@ -307,7 +307,8 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' const baseDate = after_date || todayET() const span = Math.max(1, Math.min(42, parseInt(days, 10) || SLOT_HORIZON_DAYS)) // jusqu'à 6 semaines (calendrier mois de l'horaire tech) const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i)) - const wantSkill = String(skill || '').trim().toLowerCase() + // `skill` accepte UNE compétence OU plusieurs en CSV (« réparation,sans-fil ») → le tech doit les avoir TOUTES. + const wantSkills = String(skill || '').split(',').map(s => s.trim()).filter(Boolean) const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([ erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([ @@ -325,7 +326,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' 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) => techHasSkill(t.skills || t._user_tags, wantSkill) + const hasSkill = (t) => techHasSkills(t.skills || t._user_tags, wantSkills) const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && 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). @@ -354,21 +355,37 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' 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) + // Match par technician_id OU docname (comme les quarts/congés) : les jobs legacy portent parfois l'un ou l'autre. + const dayJobsRaw = allJobs + .filter(j => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) && j.scheduled_date === dateStr) + .map(j => { const s = j.start_time ? timeToHours(j.start_time) : null; const dur = parseFloat(j.duration_h) || 1; return { start_h: s, dur, end_h: s != null ? s + dur : null, reserved: j.job_type === 'Réservation' } }) + const timed = dayJobsRaw.filter(j => j.start_h != null).sort((a, b) => a.start_h - b.start_h) + const untimed = dayJobsRaw.filter(j => j.start_h == null) // jobs assignés SANS heure fixée (legacy osTicket) — occupent quand même la journée 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 => ({ + const timedBusy = timed.reduce((acc, j) => acc + clamp(j), 0) + const untimedBusy = untimed.reduce((acc, j) => acc + j.dur, 0) + const busyH = timedBusy + untimedBusy // ⇐ correctif : les jobs sans heure comptent (avant : ignorés ⇒ « 40 h libre » alors que plein) + const blocks = timed.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, })) + // Jobs sans heure : empilés à partir de la charge horaire déjà placée, marqués « untimed » (rendu hachuré côté SPA). + let cum = timedBusy + for (const j of untimed) { + blocks.push({ + start: null, end: null, + top: shiftH > 0 ? +Math.min(1, cum / shiftH).toFixed(3) : 0, + height: shiftH > 0 ? +Math.max(0.03, j.dur / shiftH).toFixed(3) : 0, + reserved: j.reserved, untimed: true, + }) + cum += j.dur + } 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, + shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1), + jobs: dayJobsRaw.length, untimed_jobs: untimed.length, occupancy: shiftH > 0 ? Math.min(1, +(busyH / shiftH).toFixed(2)) : 0, blocks, } }) @@ -382,7 +399,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = '' }) // 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 } + return { start: baseDate, days: span, dates, skill: wantSkills.join(','), skills: wantSkills, techs: out } } // Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id). diff --git a/services/targo-hub/lib/skill-resolver.js b/services/targo-hub/lib/skill-resolver.js index 411557f..44a2ba9 100644 --- a/services/targo-hub/lib/skill-resolver.js +++ b/services/targo-hub/lib/skill-resolver.js @@ -15,7 +15,7 @@ const { log } = require('./helpers') // Vocabulaire CANONIQUE des compétences terrain (= sorties possibles de deptToSkill). // Une demande à distance / facturation / vente ne requiert AUCUNE compétence → []. -const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin'] +const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin', 'support'] function normSkill (s) { return String(s || '').trim().toLowerCase() } function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') } @@ -71,7 +71,7 @@ const DEPARTMENT_SKILLS = { Épissure: ['épissure'], Fusionneur: ['épissure'], 'Net Admin': ['netadmin'], - Support: [], // dépannage à distance → n'importe quel agent + Support: ['support'], // ne lister que les agents ayant la compétence « support » (pas les techs sans compétence) Facturation: [], Commercial: [], // vente / bureau Vente: [],