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
- occ = v" />
+ occ = v" />
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: [],