feat(ops): reason → skill → skill-aware availability module

Bridge reason → required skill → availability so the occupancy denominator
reflects the skill a job needs ("70 h installation" vs "150 h all techs").

Hub:
- lib/skill-resolver.js (NEW, SOURCE UNIQUE): shared techHasSkill(s) predicate,
  deptToSkill (moved here, re-exported from roster.js — no cycle),
  DEPARTMENT_SKILLS bridge, resolveSkills({text,department,jobType,useAI})
  (chip → rule → Gemini fallback, reusing classifyEmail pattern).
- roster.js: capacityByDay(start,days,skills) filters denominator to qualified
  techs (+ guards used/due). New POST /roster/resolve-skills, /roster/capacity?skill=.
  Backward-compatible (no skill = prior behavior).
- dispatch.js: techOccupancy/suggestSlots reuse the shared predicate.

SPA:
- config/departments.js: skills[]+dur per department + skillsForDepartment();
  added Réparation reason.
- components/shared/OccupancyBands.vue (NEW): vertical-strip grid extracted from
  SuggestSlotsDialog (now reuses it).
- components/shared/AvailabilityByReason.vue (NEW): reason chips + dictate→AI →
  resolved skill → skill-filtered bands + adjusted-denominator summary.
  Mounted in ClientDetailPage, IssueDetail, ConversationPanel.
- api/roster.js: getCapacity(start,days,skill) + resolveSkills().
- Planif + Dashboard capacity bands pass skill to /roster/capacity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-08 18:33:30 -04:00
parent 6bf323b18e
commit f4a6d07a8b
13 changed files with 581 additions and 122 deletions

View File

@ -34,8 +34,14 @@ export const listRequirements = (start, days = 7) => jget(`/roster/requirements?
export const createRequirement = (r) => jpost('/roster/requirements', r) export const createRequirement = (r) => jpost('/roster/requirements', r)
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`) export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`) export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`)
// Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) jobs placés, + charge due // Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) jobs placés, + charge due.
export const getCapacity = (start, days = 7) => jget(`/roster/capacity?start=${start}&days=${days}`) // skill (chaîne ou tableau) → dénominateur FILTRÉ : ne compte que les techs qui ont cette/ces compétence(s).
export const getCapacity = (start, days = 7, skill = '') => {
const s = (Array.isArray(skill) ? skill : (skill ? [skill] : [])).filter(Boolean)
return jget(`/roster/capacity?start=${start}&days=${days}${s.length ? '&skill=' + encodeURIComponent(s.join(',')) : ''}`)
}
// Résolveur « raison → compétence(s) » : { text?, department?, jobType?, useAI? } → { skills, primary, confidence, source, reason }.
export const resolveSkills = (body) => jpost('/roster/resolve-skills', body || {})
export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`) export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`)
export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`) export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`) export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`)

View File

@ -0,0 +1,176 @@
<script setup>
/**
* AvailabilityByReason module RÉUTILISABLE « raison compétence disponibilité ».
*
* Montable de PARTOUT (fiche client, détail de ticket, panneau de conversation/courriel) :
* 1. RAISON chips des motifs groupés par département (config/departments.js) OU « dictez la raison »
* (texte libre l'IA du hub évalue la/les compétence(s) probable(s)).
* 2. COMPÉTENCE résolue affichée + éditable.
* 3. DISPONIBILITÉ bandes d'occupation (<OccupancyBands>) dont le DÉNOMINATEUR ne compte que les
* techs qualifiés (« 70 h installation » au lieu de « 150 h tous techs »).
* 4. Bouton « Trouver un créneau » émet 'book' (skills + durée + date + contexte) pour que le parent
* ouvre SuggestSlotsDialog / UnifiedCreateModal pré-rempli.
*
* Le texte libre est pré-rempli depuis le contexte (corps du courriel / sujet du ticket) via `initialText`.
*/
import { ref, computed, watch } from 'vue'
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'
const props = defineProps({
modelValue: { type: Boolean, default: false },
initialText: { type: String, default: '' }, // corps courriel / sujet ticket pré-remplit « dictez la raison »
// Contexte optionnel repassé tel quel au parent lors du booking (fiche client / ticket).
customerName: { type: String, default: '' },
address: { type: String, default: '' },
lat: { type: Number, default: null },
lng: { type: Number, default: null },
})
const emit = defineEmits(['update:modelValue', 'book'])
const today = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const afterDate = ref(today())
const reasonText = ref('')
const dept = ref('') // département/motif choisi (chip) confiance haute
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 || []) : []
})
const primarySkill = computed(() => skills.value[0] || '')
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.
const summary = computed(() => {
const o = occ.value; if (!o || !o.techs) return null
const free = o.techs.reduce((a, t) => a + (t.free_h || 0), 0)
const shift = o.techs.reduce((a, t) => a + (t.shift_h || 0), 0)
return { techs: o.techs.length, free_h: Math.round(free * 10) / 10, shift_h: Math.round(shift * 10) / 10 }
})
// Chip cliqué = intention claire compétence(s) du département (confiance haute), pas d'IA.
function pickDept (d) {
dept.value = d.category
resolved.value = { skills: d.skills || [], primary: (d.skills || [])[0] || '', confidence: 'high', source: 'chip', reason: '' }
}
// « Dictez la raison » : régex instantanée (chips surlignés) puis IA à la soumission.
const suggested = computed(() => suggestDepartments(reasonText.value)) // départements probables (surlignage instantané)
async function evaluateAI () {
const text = reasonText.value.trim()
if (text.length < 4) return
aiLoading.value = true
try {
const r = await resolveSkills({ text, department: dept.value || '', useAI: true })
resolved.value = r
if (!dept.value && r.primary) { const d = DEPARTMENTS.find(x => (x.skills || []).includes(r.primary)); if (d) dept.value = d.category }
if (!r.skills.length) Notify.create({ type: 'info', message: 'Aucune compétence terrain requise (traité à distance) — dispo = tous les techs.', timeout: 3500 })
} catch (e) {
Notify.create({ type: 'negative', message: 'Évaluation IA indisponible : ' + (e.message || e) + ' — choisis un motif ci-dessus.', timeout: 4000 })
} finally { aiLoading.value = false }
}
function clearReason () { dept.value = ''; resolved.value = null; reasonText.value = '' }
function book () {
emit('book', {
skills: skills.value, primary: primarySkill.value, duration: durH.value, date: afterDate.value,
customerName: props.customerName, address: props.address, lat: props.lat, lng: props.lng,
})
emit('update:modelValue', false)
}
// Réinitialise + pré-remplit à chaque ouverture.
watch(() => props.modelValue, (o) => {
if (!o) return
afterDate.value = today(); dept.value = ''; resolved.value = null; occ.value = null
reasonText.value = props.initialText || ''
})
const confidenceLabel = { high: 'motif choisi', medium: 'évalué', low: 'incertain' }
const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
function onOccDay (cell) { if (cell && !cell.off) afterDate.value = cell.date }
function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
</script>
<template>
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)">
<q-card style="width:560px;max-width:96vw">
<q-card-section class="row items-center q-pb-none">
<q-icon name="event_available" color="primary" size="24px" class="q-mr-sm" />
<div class="text-h6">Disponibilité par motif</div>
<q-space /><q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
</q-card-section>
<q-card-section class="q-gutter-sm">
<!-- 1. Motifs (chips par département) -->
<div class="text-caption text-grey-7">Motif de la demande</div>
<div class="row items-center q-gutter-xs">
<q-chip v-for="d in DEPARTMENTS" :key="d.category" clickable dense
:color="dept === d.category ? d.color : (suggested.some(s => s.category === d.category) ? 'amber-2' : 'grey-3')"
:text-color="dept === d.category ? 'white' : 'grey-9'"
:icon="d.icon" @click="pickDept(d)">
{{ d.category }}
<q-tooltip>{{ d.skills.length ? 'Compétence : ' + d.skills.join(', ') + ' · ' + d.dur + ' h par défaut' : 'À distance / bureau — aucune compétence terrain' }}</q-tooltip>
</q-chip>
</div>
<!-- 1b. Dictez la raison IA -->
<q-input dense outlined v-model="reasonText" type="textarea" autogrow :rows="1"
label="…ou dictez la raison (l'IA évalue la compétence)" clearable @keyup.enter.exact="evaluateAI">
<template #prepend><q-icon name="mic" /></template>
<template #append>
<q-btn flat dense no-caps color="primary" icon="auto_awesome" :loading="aiLoading"
:disable="reasonText.trim().length < 4" label="Évaluer" @click="evaluateAI" />
</template>
</q-input>
<!-- 2. Compétence résolue -->
<div v-if="resolved" class="row items-center q-gutter-xs resolved-row">
<q-icon :name="skills.length ? 'engineering' : 'support_agent'" size="18px" :color="skills.length ? 'teal-7' : 'blue-grey-5'" />
<span v-if="skills.length" class="text-weight-medium">
Compétence requise :
<q-chip v-for="s in skills" :key="s" dense square color="teal-1" text-color="teal-9" class="q-ml-xs">{{ s }}</q-chip>
</span>
<span v-else class="text-blue-grey-7">Aucune compétence terrain traité à distance (tous les agents).</span>
<q-badge outline color="grey-6" class="q-ml-xs">{{ confidenceLabel[resolved.confidence] || resolved.confidence }}</q-badge>
<q-btn flat dense round size="sm" icon="close" color="grey-6" @click="clearReason"><q-tooltip>Effacer le motif</q-tooltip></q-btn>
<div v-if="resolved.reason" class="full-width text-caption text-grey-6 q-pl-md">« {{ resolved.reason }} »</div>
</div>
<!-- 3. Disponibilité filtrée : dénominateur ajusté -->
<template v-if="resolved">
<q-separator class="q-my-xs" />
<div class="row items-center">
<q-input dense outlined type="date" v-model="afterDate" label="Dès le" style="width:170px" />
<q-space />
<div v-if="summary" class="text-right">
<div class="text-h6 text-teal-8" style="line-height:1.1">{{ summary.free_h }} h <span class="text-caption text-grey-6">libres</span></div>
<div class="text-caption text-grey-7">{{ summary.techs }} tech{{ summary.techs > 1 ? 's' : '' }} qualifié{{ summary.techs > 1 ? 's' : '' }}{{ primarySkill ? ' « ' + primarySkill + ' »' : '' }} · {{ summary.shift_h }} h de quart</div>
</div>
</div>
<OccupancyBands :skill="primarySkill" :after-date="afterDate" :days="7" :active="true" @day="onOccDay" @loaded="v => occ = v" />
</template>
<div v-else class="text-caption text-grey-6 q-pt-xs">Choisis un motif ou dicte la raison pour voir la disponibilité ajustée à la compétence.</div>
</q-card-section>
<q-card-actions v-if="resolved" align="right" class="q-px-md q-pb-md">
<q-btn flat no-caps color="grey-7" label="Fermer" @click="emit('update:modelValue', false)" />
<q-btn unelevated no-caps color="primary" icon="event" label="Trouver un créneau" @click="book">
<q-tooltip>Ouvre la recherche de créneau pré-remplie ({{ primarySkill || 'sans compétence' }} · {{ durH }} h · dès {{ dayShort(afterDate) }}).</q-tooltip>
</q-btn>
</q-card-actions>
</q-card>
</q-dialog>
</template>
<style scoped>
.resolved-row { border: 1px dashed #cbd5e1; border-radius: 8px; padding: 6px 10px; }
</style>

View File

@ -96,7 +96,7 @@
<q-item-label class="text-weight-medium" style="font-size:0.85rem" lines="2">{{ ticketTitle(t) }}</q-item-label> <q-item-label class="text-weight-medium" style="font-size:0.85rem" lines="2">{{ ticketTitle(t) }}</q-item-label>
<q-item-label caption lines="1"> <q-item-label caption lines="1">
<q-badge v-if="ticketCategory(t)" dense color="green-1" text-color="green-9" class="q-mr-xs" style="font-size:0.6rem">{{ ticketCategory(t) }}</q-badge> <q-badge v-if="ticketCategory(t)" dense color="green-1" text-color="green-9" class="q-mr-xs" style="font-size:0.6rem">{{ ticketCategory(t) }}</q-badge>
<span class="text-grey-6">{{ t.customer || '—' }}</span> <router-link v-if="t.customer" :to="'/clients/' + encodeURIComponent(t.customer)" class="erp-link" @click.stop>{{ t.customer }}</router-link><span v-else class="text-grey-6"></span>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section side top> <q-item-section side top>
@ -145,6 +145,9 @@
<q-btn flat dense size="sm" icon="add_task" color="positive" @click="openTask()"> <q-btn flat dense size="sm" icon="add_task" color="positive" @click="openTask()">
<q-tooltip>Ajouter ce fil à une tâche (existante ou nouvelle)</q-tooltip> <q-tooltip>Ajouter ce fil à une tâche (existante ou nouvelle)</q-tooltip>
</q-btn> </q-btn>
<q-btn flat dense size="sm" icon="fact_check" color="indigo-6" @click="availReasonOpen = true">
<q-tooltip>Dispo par motif : compétence probable de cette demande disponibilité ajustée</q-tooltip>
</q-btn>
<q-btn v-if="isEmailConv && activeDiscussion.email" flat dense size="sm" icon="sync" color="info" :loading="resyncing" @click="doResync"> <q-btn v-if="isEmailConv && activeDiscussion.email" flat dense size="sm" icon="sync" color="info" :loading="resyncing" @click="doResync">
<q-tooltip>Re-synchroniser depuis Gmail récupère les réponses envoyées hors hub + les courriels manqués (panne / sortis de la boîte)</q-tooltip> <q-tooltip>Re-synchroniser depuis Gmail récupère les réponses envoyées hors hub + les courriels manqués (panne / sortis de la boîte)</q-tooltip>
</q-btn> </q-btn>
@ -1010,6 +1013,11 @@
<!-- COURRIEL AGRANDI iframe sandbox plein écran (composant extrait) --> <!-- COURRIEL AGRANDI iframe sandbox plein écran (composant extrait) -->
<EmailExpandDialog v-model="emailExpand.open" :html="emailExpand.html" :subject="emailExpand.subject" /> <EmailExpandDialog v-model="emailExpand.open" :html="emailExpand.html" :subject="emailExpand.subject" />
<!-- « Dispo par motif » depuis un courriel/SMS : sujet pré-rempli l'IA/les chips évaluent la compétence disponibilité filtrée -->
<AvailabilityByReason v-model="availReasonOpen"
:initial-text="convSubject || (activeDiscussion && activeDiscussion.subject) || ''"
:customer-name="(activeDiscussion && (activeDiscussion.customerName || activeDiscussion.email || activeDiscussion.phone)) || ''" />
<!-- Ajouter le fil courant à une tâche (réutilisable conversation/ticket/projet) --> <!-- Ajouter le fil courant à une tâche (réutilisable conversation/ticket/projet) -->
<AddToTaskDialog v-model="addTaskOpen" :source="activeTaskSrc || taskSource" /> <AddToTaskDialog v-model="addTaskOpen" :source="activeTaskSrc || taskSource" />
</template> </template>
@ -1030,6 +1038,7 @@ import { useAuthStore } from 'src/stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import AddToTaskDialog from 'src/components/shared/AddToTaskDialog.vue' import AddToTaskDialog from 'src/components/shared/AddToTaskDialog.vue'
import EmailExpandDialog from 'src/components/shared/conversation/EmailExpandDialog.vue' import EmailExpandDialog from 'src/components/shared/conversation/EmailExpandDialog.vue'
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison compétence disponibilité (dénominateur filtré)
import { emailSrcdoc } from 'src/composables/useEmailRender' import { emailSrcdoc } from 'src/composables/useEmailRender'
const $q = useQuasar() const $q = useQuasar()
@ -1048,6 +1057,7 @@ const {
} = useConversations() } = useConversations()
// Identité d'expédition « De : » défaut = groupe (anonymisé), modifiable par message // Identité d'expédition « De : » défaut = groupe (anonymisé), modifiable par message
const availReasonOpen = ref(false) // « Dispo par motif » depuis la conversation
const senders = ref({ default: '', identities: [] }) const senders = ref({ default: '', identities: [] })
const replySendAs = ref('') const replySendAs = ref('')
const composeSendAs = ref('') const composeSendAs = ref('')

View File

@ -0,0 +1,117 @@
<script setup>
/**
* OccupancyBands bandes verticales « qui a de la place », FILTRÉES par compétence.
*
* Grille tech × jours : chaque case = mini-timeline du quart (blocs occupé bleu / réservé orange,
* week-end/absence hachuré) + barre d'occupation par tech (vert<50%<ambre<80%<rouge), triée le moins
* occupé d'abord. Le DÉNOMINATEUR (heures dispo) ne compte QUE les techs qui ont la compétence `skill`
* « 70 h installation » au lieu de « 150 h tous techs ».
*
* Extrait de SuggestSlotsDialog pour être RÉUTILISABLE (SuggestSlotsDialog + AvailabilityByReason).
* Se recharge seul quand skill/afterDate/days changent ; émet 'loaded' (occ) pour les résumés du parent
* et 'day' (cell) au clic sur un jour.
*/
import { ref, watch, onMounted } from 'vue'
import { Notify } from 'quasar'
import { techOccupancy } from 'src/api/dispatch'
const props = defineProps({
skill: { type: String, default: '' },
afterDate: { type: String, default: () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) },
days: { type: Number, default: 7 },
active: { type: Boolean, default: true }, // ne charge que si actif (ex. panneau replié false)
})
const emit = defineEmits(['loaded', 'day'])
const occ = ref(null)
const occLoading = ref(false)
let occTimer = null
async function load () {
if (!props.active) return
occLoading.value = true
try {
occ.value = await techOccupancy({ after_date: props.afterDate, days: props.days, skill: props.skill })
emit('loaded', occ.value)
} catch (e) {
occ.value = null; emit('loaded', null)
Notify.create({ type: 'negative', message: 'Occupation indisponible : ' + (e.message || e), timeout: 3000 })
} finally { occLoading.value = false }
}
function schedule () { clearTimeout(occTimer); occTimer = setTimeout(load, 250) }
watch(() => [props.skill, props.afterDate, props.days], schedule)
watch(() => props.active, (v) => { if (v && !occ.value) load() })
onMounted(() => { if (props.active) load() })
// Rendu (identique à l'ancienne grille de SuggestSlotsDialog).
const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
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' }
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 onDay (cell) { if (!cell || cell.off) return; emit('day', cell) }
defineExpose({ reload: load })
</script>
<template>
<div>
<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 }">
<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>
<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="onDay(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 clickable">
<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>
</template>
<style scoped>
.occ-grid { display: grid; grid-template-columns: 118px repeat(var(--cols), 1fr); gap: 3px; align-items: stretch; max-height: 42vh; overflow: auto; padding: 2px; }
.occ-corner { background: transparent; }
.occ-dayh { font-size: 10px; text-align: center; color: #64748b; font-weight: 600; padding-bottom: 2px; text-transform: capitalize; }
.occ-dayh.we { color: #b45309; }
.occ-name { min-width: 0; padding: 2px 4px 2px 0; display: flex; flex-direction: column; justify-content: center; font-size: 11.5px; }
.occ-name .ellipsis { max-width: 112px; }
.occ-bar { height: 4px; background: #e5e7eb; border-radius: 3px; overflow: hidden; margin: 2px 0; }
.occ-bar-fill { height: 100%; border-radius: 3px; transition: width .2s; }
.occ-sub { font-size: 9.5px; color: #64748b; }
.occ-cell { display: flex; }
.occ-strip { position: relative; flex: 1; min-height: 46px; border-radius: 4px; background: #dcfce7; border: 1px solid #bbf7d0; overflow: hidden; cursor: pointer; }
.occ-strip.off { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 4px, #e2e8f0 4px, #e2e8f0 8px); border-color: #e2e8f0; cursor: default; }
.occ-block { position: absolute; left: 1px; right: 1px; background: #2563eb; border-radius: 2px; }
.occ-block.reserved { background: #f59e0b; }
.occ-legend { font-size: 10.5px; color: #475569; }
.occ-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
.occ-dot.job { background: #2563eb; }
.occ-dot.reserved { background: #f59e0b; }
.occ-dot.free { background: #dcfce7; border: 1px solid #bbf7d0; }
.occ-dot.offd { background: #e2e8f0; }
</style>

View File

@ -12,7 +12,7 @@
<div class="cust-banner q-mb-sm"> <div class="cust-banner q-mb-sm">
<q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" /> <q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" />
<template v-if="doc.customer"> <template v-if="doc.customer">
<span class="cust-name erp-link" @click="$emit('navigate', 'Customer', doc.customer)">{{ customerLabel || doc.customer }}</span> <router-link class="cust-name erp-link" :to="'/clients/' + encodeURIComponent(doc.customer)" style="text-decoration:underline;text-underline-offset:2px">{{ customerLabel || doc.customer }}<q-icon name="open_in_new" size="12px" class="q-ml-xs" /></router-link>
<q-btn flat dense round size="xs" icon="link_off" color="grey-6" :loading="linkingCust" @click="unlinkCustomer"> <q-btn flat dense round size="xs" icon="link_off" color="grey-6" :loading="linkingCust" @click="unlinkCustomer">
<q-tooltip>Délier le client</q-tooltip> <q-tooltip>Délier le client</q-tooltip>
</q-btn> </q-btn>
@ -145,6 +145,11 @@
<q-item-section avatar><q-icon name="playlist_add" color="green-7" /></q-item-section> <q-item-section avatar><q-icon name="playlist_add" color="green-7" /></q-item-section>
<q-item-section><q-item-label>Projet</q-item-label><q-item-label caption>Modèle multi-étapes (installation) les tâches en découlent</q-item-label></q-item-section> <q-item-section><q-item-label>Projet</q-item-label><q-item-label caption>Modèle multi-étapes (installation) les tâches en découlent</q-item-label></q-item-section>
</q-item> </q-item>
<q-separator />
<q-item clickable v-close-popup @click="availReasonOpen = true">
<q-item-section avatar><q-icon name="fact_check" color="indigo-7" /></q-item-section>
<q-item-section><q-item-label>Dispo par motif</q-item-label><q-item-label caption>Compétence requise disponibilité ajustée, puis créer la tâche</q-item-label></q-item-section>
</q-item>
</q-list> </q-list>
</q-btn-dropdown> </q-btn-dropdown>
</div> </div>
@ -279,6 +284,14 @@
:existing-jobs="dispatchJobs" :existing-jobs="dispatchJobs"
@created="onJobCreated" @created="onJobCreated"
/> />
<!-- « Dispo par motif » : raison (sujet/description pré-remplis) compétence disponibilité filtrée créer la tâche -->
<AvailabilityByReason
v-model="availReasonOpen"
:initial-text="[doc.subject, doc.description].filter(Boolean).join(' — ')"
:customer-name="customerLabel || doc.customer || ''"
@book="onAvailBook"
/>
</template> </template>
<script setup> <script setup>
@ -301,6 +314,7 @@ import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue' import TaskNode from 'src/components/shared/TaskNode.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' import TagEditor from 'src/components/shared/TagEditor.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue' import AssignmentField from 'src/components/shared/AssignmentField.vue'
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison compétence disponibilité (dénominateur filtré)
const props = defineProps({ const props = defineProps({
doc: { type: Object, required: true }, doc: { type: Object, required: true },
@ -322,6 +336,8 @@ const senderFull = computed(() => ((userName.value || '').trim() || (authStore.u
const sendingReply = ref(false) const sendingReply = ref(false)
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
const showProjectWizard = ref(false) const showProjectWizard = ref(false)
const availReasonOpen = ref(false)
const bookedSkill = ref('') // compétence résolue par « Dispo par motif » pré-remplit la tâche créée
const dispatchContext = computed(() => ({ const dispatchContext = computed(() => ({
subject: props.doc?.subject || '', subject: props.doc?.subject || '',
@ -330,7 +346,10 @@ const dispatchContext = computed(() => ({
service_location: props.doc?.service_location || '', service_location: props.doc?.service_location || '',
issue_type: props.doc?.issue_type || '', issue_type: props.doc?.issue_type || '',
priority: props.doc?.priority || 'medium', priority: props.doc?.priority || 'medium',
tags: bookedSkill.value ? [bookedSkill.value] : [], // compétence issue de « Dispo par motif » compétence requise de la tâche
})) }))
// « Dispo par motif » a résolu une compétence pré-remplit + ouvre la création de tâche liée à ce ticket.
function onAvailBook (b) { bookedSkill.value = b.primary || ''; showCreateDialog.value = true }
// Client lié + fil unifié // Client lié + fil unifié

View File

@ -5,23 +5,40 @@
* on fait ressortir le(s) département(s) le(s) plus probable(s) chips cliquables, sans choisir dans une liste. * on fait ressortir le(s) département(s) le(s) plus probable(s) chips cliquables, sans choisir dans une liste.
* `category` = la catégorie du ticket (préfixe [Cat]) ; `queue` = l'équipe notifiée (files de l'Inbox). * `category` = la catégorie du ticket (préfixe [Cat]) ; `queue` = l'équipe notifiée (files de l'Inbox).
* *
* Éditer ici pour ajuster les mots-clés une seule source. * SOURCE UNIQUE aussi pour le pont RAISON COMPÉTENCE(S) requise(s) :
* `skills` = compétences terrain que la demande exige ([] = traité à distance / au bureau n'importe qui) ;
* `dur` = durée par défaut du rendez-vous (h) proposée quand on choisit ce motif.
* Le composant <AvailabilityByReason> lit ces champs pour FILTRER le dénominateur des bandes d'occupation
* (« 70 h installation » au lieu de « 150 h tous techs »). Doit rester aligné sur lib/skill-resolver.js (hub).
*
* Éditer ici pour ajuster les mots-clés / compétences une seule source.
*/ */
export const DEPARTMENTS = [ export const DEPARTMENTS = [
{ category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary', { category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary', skills: [], 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 }, 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', { 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 }, 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 },
{ category: 'Installation', queue: 'Technicien', icon: 'construction', color: 'orange-7', { category: 'Installation', queue: 'Technicien', icon: 'construction', color: 'orange-7', skills: ['installation'], dur: 2,
re: /\b(installation|installer|rendez-?vous|rdv|branchement|brancher|raccord|technicien|d[ée]placement|visite)\b/i }, re: /\b(installation|installer|rendez-?vous|rdv|branchement|brancher|raccord|technicien|d[ée]placement|visite)\b/i },
{ category: 'Télévision', queue: 'Technicien', icon: 'live_tv', color: 'purple-6', { category: 'Réparation', queue: 'Technicien', icon: 'build', color: 'red-7', skills: ['réparation'], dur: 1.5,
re: /\b(r[ée]par|d[ée]pann|bris|brisé|cass[ée]|d[ée]fectueu|remplacer|ne fonctionne plus|probl[èe]me technique|d[ée]sinstall)\b/i },
{ category: 'Télévision', queue: 'Technicien', icon: 'live_tv', color: 'purple-6', skills: ['tv'], dur: 1,
re: /\b(t[ée]l[ée]vision|t[ée]l[ée]\b|\btv\b|iptv|cha[îi]ne|d[ée]codeur|illico|ministra|enregistreur|t[ée]l[ée]command)\b/i }, re: /\b(t[ée]l[ée]vision|t[ée]l[ée]\b|\btv\b|iptv|cha[îi]ne|d[ée]codeur|illico|ministra|enregistreur|t[ée]l[ée]command)\b/i },
{ category: 'Téléphonie', queue: 'Technicien', icon: 'call', color: 'teal-7', { category: 'Téléphonie', queue: 'Technicien', icon: 'call', color: 'teal-7', skills: ['telephone'], dur: 1,
re: /\b(t[ée]l[ée]phon|voip|tonalit[ée]|ligne t[ée]l|appel sortant|messagerie vocale|combin[ée]|sip)\b/i }, re: /\b(t[ée]l[ée]phon|voip|tonalit[ée]|ligne t[ée]l|appel sortant|messagerie vocale|combin[ée]|sip)\b/i },
{ category: 'Commercial', queue: 'Service à la clientèle', icon: 'sell', color: 'blue-7', { category: 'Commercial', queue: 'Service à la clientèle', icon: 'sell', color: 'blue-7', skills: [], dur: 1,
re: /\b(forfait|abonnement|prix|soumission|nouveau client|d[ée]m[ée]nage|upgrade|am[ée]liorer|vitesse|offre|promotion|r[ée]siliation|r[ée]silier|annulation|annuler|fermer le compte)\b/i }, re: /\b(forfait|abonnement|prix|soumission|nouveau client|d[ée]m[ée]nage|upgrade|am[ée]liorer|vitesse|offre|promotion|r[ée]siliation|r[ée]silier|annulation|annuler|fermer le compte)\b/i },
] ]
/**
* skillsForDepartment(category) tableau de compétences requises (miroir client de DEPARTMENT_SKILLS du hub).
* '' / inconnu [] (aucune compétence dénominateur = tous les techs).
*/
export function skillsForDepartment (category) {
const d = DEPARTMENTS.find(x => x.category === category)
return d ? (d.skills || []) : []
}
/** /**
* suggestDepartments(text) départements probables, classés par nombre de mots-clés trouvés. * suggestDepartments(text) départements probables, classés par nombre de mots-clés trouvés.
* Renvoie [{ category, queue, icon, color, keyword, score }]. Le 1er = le plus probable. * Renvoie [{ category, queue, icon, color, keyword, score }]. Le 1er = le plus probable.

View File

@ -9,8 +9,9 @@
*/ */
import { reactive, ref, watch } from 'vue' import { reactive, ref, watch } from 'vue'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { suggestSlots, techOccupancy } from 'src/api/dispatch' import { suggestSlots } from 'src/api/dispatch'
import { useAddressSearch } from 'src/composables/useAddressSearch' import { useAddressSearch } from 'src/composables/useAddressSearch'
import OccupancyBands from 'src/components/shared/OccupancyBands.vue' // bandes d'occupation partagées (dénominateur filtré par compétence)
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@ -34,21 +35,10 @@ const slots = ref([])
const searched = ref(false) const searched = ref(false)
// Occupation des ressources (bandes verticales) // 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 // Panneau dépliable : la grille elle-même vit dans <OccupancyBands> (partagée). Ici on ne garde que
// AVANT même de saisir l'adresse (l'occupation ne dépend que du skill + de la date, pas du lieu). // l'état du panneau + le dernier `occ` (pour la légende/caption). Le composant se recharge seul.
const occOpen = ref(false) const occOpen = ref(false)
const occ = ref(null) 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). // Reset à l'ouverture (formulaire propre à chaque fois) puis pré-remplissage éventuel (adresse/coords/compétence du contexte appelant).
watch(() => props.modelValue, (o) => { watch(() => props.modelValue, (o) => {
@ -61,13 +51,8 @@ watch(() => props.modelValue, (o) => {
}) })
function pickSkill (s) { skill.value = s.skill; durationH.value = s.dur || 1 } // 1 clic skill + durée par défaut 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). // Clic sur une case-jour de l'occupation (émis par <OccupancyBands>) 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() } 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 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 onAddrInput (v) { target.latitude = null; target.longitude = null; searchAddr(v) } // retape l'adresse invalide les coords
function onPickAddr (a) { selectAddr(a, target); addrResults.value = [] } function onPickAddr (a) { selectAddr(a, target); addrResults.value = [] }
@ -115,38 +100,7 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
icon="bar_chart" :label="skill ? 'Occupation — « ' + skill + ' »' : 'Occupation des ressources'" 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'"> :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 class="q-pt-xs">
<div v-if="occLoading" class="text-center q-pa-sm"><q-spinner size="22px" color="primary" /></div> <OccupancyBands :skill="skill" :after-date="afterDate" :days="7" :active="occOpen" @day="onOccDay" @loaded="v => occ = v" />
<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> </div>
</q-expansion-item> </q-expansion-item>
@ -204,27 +158,5 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
<style scoped> <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-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; } .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; } .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> </style>

View File

@ -27,6 +27,9 @@
<q-btn dense flat size="sm" no-caps color="teal-8" icon="event_available" label="Trouver un créneau" @click="openFindSlot"> <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-tooltip>Prendre RDV (réparation / installation) pré-rempli avec l'adresse du client</q-tooltip>
</q-btn> </q-btn>
<q-btn dense flat size="sm" no-caps color="indigo-7" icon="fact_check" label="Dispo par motif" @click="openAvailReason">
<q-tooltip>Choisir/dicter le motif voir la disponibilité ajustée à la compétence requise</q-tooltip>
</q-btn>
<q-btn dense flat size="sm" no-caps color="warning" icon="card_giftcard" label="Récompense" @click="rewardOpen = true"> <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-tooltip>Carte-cadeau : voir / copier le lien, éditer ou envoyer par courriel</q-tooltip>
</q-btn> </q-btn>
@ -593,6 +596,9 @@
<!-- « Trouver un créneau » direct (appel / réparation) crée un Dispatch Job lié au client --> <!-- « Trouver un créneau » direct (appel / réparation) crée un Dispatch Job lié au client -->
<SuggestSlotsDialog v-model="findSlotOpen" :skills="slotSkills" <SuggestSlotsDialog v-model="findSlotOpen" :skills="slotSkills"
:initial-address="findSlotAddr" :initial-skill="findSlotSkill" @select="onFindSlotSelected" /> :initial-address="findSlotAddr" :initial-skill="findSlotSkill" @select="onFindSlotSelected" />
<!-- « Dispo par motif » : raison compétence disponibilité (dénominateur filtré) enchaîne sur la recherche de créneau -->
<AvailabilityByReason v-model="availReasonOpen" :customer-name="customer?.customer_name || ''"
:address="findSlotAddr" @book="onAvailBook" />
<UnifiedCreateModal v-model="createJobOpen" mode="work-order" <UnifiedCreateModal v-model="createJobOpen" mode="work-order"
:context="createJobCtx" :locations="locations" @created="onJobCreated" /> :context="createJobCtx" :locations="locations" @created="onJobCreated" />
@ -705,6 +711,8 @@ import InlineField from 'src/components/shared/InlineField.vue'
import { useSoftphone } from 'src/composables/useSoftphone' import { useSoftphone } from 'src/composables/useSoftphone'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' 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 SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » direct depuis la fiche (appel/réparation)
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison compétence disponibilité (dénominateur filtré)
import { DEPARTMENTS } from 'src/config/departments'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue' import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue' import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
@ -1198,14 +1206,12 @@ async function loadJobs () {
const findSlotOpen = ref(false) const findSlotOpen = ref(false)
const findSlotAddr = ref('') const findSlotAddr = ref('')
const findSlotSkill = ref('') const findSlotSkill = ref('')
const availReasonOpen = ref(false)
const createJobOpen = ref(false) const createJobOpen = ref(false)
const createJobCtx = ref({}) const createJobCtx = ref({})
const slotSkills = [ // Chips de compétence dérivés de la SOURCE UNIQUE (config/departments.js) plus de liste codée en dur.
{ skill: 'réparation', dur: 1, color: '#ef4444' }, const SKILL_COLORS = { installation: '#f59e0b', 'réparation': '#ef4444', tv: '#8b5cf6', telephone: '#0ea5e9', 'épissure': '#10b981', monteur: '#6366f1' }
{ skill: 'installation', dur: 2, color: '#f59e0b' }, const slotSkills = DEPARTMENTS.filter(d => (d.skills || []).length).map(d => ({ skill: d.skills[0], dur: d.dur || 1, color: SKILL_COLORS[d.skills[0]] || '#64748b' }))
{ skill: 'tv', dur: 1, color: '#8b5cf6' },
{ skill: 'téléphonie', dur: 0.5, color: '#0ea5e9' },
]
function primaryLocation () { function primaryLocation () {
const list = (sortedLocations.value && sortedLocations.value.length) ? sortedLocations.value : locations.value const list = (sortedLocations.value && sortedLocations.value.length) ? sortedLocations.value : locations.value
return (list || []).find(l => locHasSubs(l.name)) || (list || [])[0] || null return (list || []).find(l => locHasSubs(l.name)) || (list || [])[0] || null
@ -1216,6 +1222,18 @@ function openFindSlot () {
findSlotSkill.value = 'réparation' // appel client = réparation le plus souvent ; l'agent peut changer la compétence findSlotSkill.value = 'réparation' // appel client = réparation le plus souvent ; l'agent peut changer la compétence
findSlotOpen.value = true findSlotOpen.value = true
} }
// « Dispo par motif » : pré-remplit l'adresse du lieu de service puis ouvre le widget raisoncompétencedispo.
function openAvailReason () {
const loc = primaryLocation()
findSlotAddr.value = (loc && (loc.address_line || loc.address_full)) || ''
availReasonOpen.value = true
}
// Le widget a résolu une compétence enchaîne sur la recherche de créneau, pré-remplie (compétence + adresse).
function onAvailBook (b) {
findSlotSkill.value = b.primary || ''
findSlotAddr.value = b.address || findSlotAddr.value
findSlotOpen.value = true
}
function onFindSlotSelected (slot) { function onFindSlotSelected (slot) {
const loc = primaryLocation() const loc = primaryLocation()
createJobCtx.value = { createJobCtx.value = {

View File

@ -25,8 +25,13 @@
<div class="row items-center q-mb-sm"> <div class="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">Charge <span class="text-caption text-grey-6 q-ml-xs">2 semaines</span></div> <div class="text-subtitle1 text-weight-bold">Charge <span class="text-caption text-grey-6 q-ml-xs">2 semaines</span></div>
<q-space /> <q-space />
<!-- Filtre compétence : le dénominateur ne compte que les techs qualifiés (« libre » = pour CE type de job). -->
<q-chip v-for="s in CAP_SKILLS" :key="s" clickable dense size="sm"
:color="capSkill === s ? 'teal' : 'grey-3'" :text-color="capSkill === s ? 'white' : 'grey-8'"
@click="capSkill = (capSkill === s ? '' : s); loadWeek()">{{ s }}</q-chip>
<q-btn flat dense no-caps size="sm" color="primary" icon-right="chevron_right" label="Planification" @click="$router.push('/planification')" /> <q-btn flat dense no-caps size="sm" color="primary" icon-right="chevron_right" label="Planification" @click="$router.push('/planification')" />
</div> </div>
<div v-if="capSkill" class="text-caption text-teal-7 q-mb-xs">Disponibilité pour « {{ capSkill }} » seuls les techs qualifiés comptent.</div>
<template v-if="weekLoad.length"> <template v-if="weekLoad.length">
<div class="dash-wk-lbl">Cette semaine</div> <div class="dash-wk-lbl">Cette semaine</div>
<OccupancyStrip :days="weekLoad.slice(0, 7)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" /> <OccupancyStrip :days="weekLoad.slice(0, 7)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
@ -144,7 +149,7 @@
</q-item-section> </q-item-section>
<q-item-section> <q-item-section>
<q-item-label>{{ t.subject }}</q-item-label> <q-item-label>{{ t.subject }}</q-item-label>
<q-item-label caption>{{ t.customer_name || t.customer }}</q-item-label> <q-item-label caption><router-link v-if="t.customer" :to="'/clients/' + encodeURIComponent(t.customer)" class="erp-link" @click.stop>{{ t.customer_name || t.customer }}</router-link><template v-else>{{ t.customer_name }}</template></q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
<span class="ops-badge open">{{ t.priority }}</span> <span class="ops-badge open">{{ t.priority }}</span>
@ -174,7 +179,7 @@
<q-item-label>{{ j.subject || j.name }}</q-item-label> <q-item-label>{{ j.subject || j.name }}</q-item-label>
<q-item-label caption> <q-item-label caption>
<span v-if="j.assigned_tech">{{ j.assigned_tech }}</span> <span v-if="j.assigned_tech">{{ j.assigned_tech }}</span>
<span v-if="j.customer"> &middot; {{ j.customer }}</span> <template v-if="j.customer"> &middot; <router-link :to="'/clients/' + encodeURIComponent(j.customer)" class="erp-link" @click.stop>{{ j.customer }}</router-link></template>
</q-item-label> </q-item-label>
</q-item-section> </q-item-section>
<q-item-section side> <q-item-section side>
@ -204,6 +209,7 @@ import { HUB_SSE_URL } from 'src/config/dispatch'
import { startOfWeek, localDateStr } from 'src/composables/useHelpers' import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue' import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
import { DEPARTMENTS } from 'src/config/departments'
const $q = useQuasar() const $q = useQuasar()
@ -221,13 +227,16 @@ const todayJobs = ref([])
// Charge de la semaine (bande d'occupation, réutilise /roster/capacity) // Charge de la semaine (bande d'occupation, réutilise /roster/capacity)
const weekLoad = ref([]) const weekLoad = ref([])
const weekUnplaced = ref(0) const weekUnplaced = ref(0)
// Compétences terrain sélectionnables (SOURCE UNIQUE departments.js) dénominateur filtré.
const CAP_SKILLS = [...new Set(DEPARTMENTS.flatMap(d => d.skills || []))]
const capSkill = ref('')
const todayIso = localDateStr(new Date()) const todayIso = localDateStr(new Date())
const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam'] const FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
async function loadWeek () { async function loadWeek () {
try { try {
const mon = startOfWeek(new Date()) // lundi de la semaine courante 2 semaines (courante + prochaine) const mon = startOfWeek(new Date()) // lundi de la semaine courante 2 semaines (courante + prochaine)
const { capacity } = await getCapacity(localDateStr(mon), 14) const { capacity } = await getCapacity(localDateStr(mon), 14, capSkill.value)
const days = []; let unplaced = 0 const days = []; let unplaced = 0
for (let i = 0; i < 14; i++) { for (let i = 0; i < 14; i++) {
const dt = new Date(mon); dt.setDate(mon.getDate() + i) const dt = new Date(mon); dt.setDate(mon.getDate() + i)

View File

@ -1400,7 +1400,8 @@
<q-icon name="assignment" color="primary" size="22px" class="q-mr-sm" /> <q-icon name="assignment" color="primary" size="22px" class="q-mr-sm" />
<div class="col" style="min-width:0"> <div class="col" style="min-width:0">
<div class="text-h6" style="line-height:1.25;word-break:break-word">{{ jobDetail.subject }}</div> <div class="text-h6" style="line-height:1.25;word-break:break-word">{{ jobDetail.subject }}</div>
<div class="text-caption text-grey-7"><template v-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div> <div class="text-caption text-grey-7"><router-link v-if="jobDetail.customerId" :to="'/clients/' + encodeURIComponent(jobDetail.customerId)" class="erp-link">{{ jobDetail.customer || jobDetail.customerId }}<q-icon name="open_in_new" size="11px" class="q-ml-xs" /></router-link><template v-else-if="jobDetail.customer">{{ jobDetail.customer }}</template><template v-if="jobDetail.lid"> · Ticket #{{ jobDetail.lid }}</template><template v-if="jobDetail.dept"> · {{ jobDetail.dept }}</template></div>
<div v-if="jobDetail.name" class="text-caption text-grey-5">{{ jobDetail.name }}</div>
<!-- Assignation MULTIFONCTION UN champ : À = assigné (lead) · CC = assistant (renfort, créneau grisé) · # = follower (abonné). <!-- Assignation MULTIFONCTION UN champ : À = assigné (lead) · CC = assistant (renfort, créneau grisé) · # = follower (abonné).
Champ partagé AssignmentField (réutilisé sur le détail ticket). « Assist » / « #follow » étendent l'autosuggest à tous les utilisateurs. --> Champ partagé AssignmentField (réutilisé sur le détail ticket). « Assist » / « #follow » étendent l'autosuggest à tous les utilisateurs. -->
<AssignmentField class="q-mt-xs jd-assign" <AssignmentField class="q-mt-xs jd-assign"
@ -1414,7 +1415,9 @@
</template> </template>
</AssignmentField> </AssignmentField>
</div> </div>
<q-space /><q-btn flat round dense icon="close" v-close-popup /> <q-space />
<q-btn v-if="jobDetail.name" flat round dense icon="open_in_new" @click="openExternal(erpLink('Dispatch Job', jobDetail.name))"><q-tooltip>Ouvrir dans ERPNext</q-tooltip></q-btn>
<q-btn flat round dense icon="close" v-close-popup />
</q-card-section> </q-card-section>
<q-separator /> <q-separator />
<q-card-section class="jd-body scroll"> <q-card-section class="jd-body scroll">
@ -1831,7 +1834,7 @@
<span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span> <span class="de-dot" :style="{ background: j.skill ? getTagColor(j.skill) : prioColor(j.priority) }"></span>
<div class="col" style="min-width:0;cursor:pointer" @click="toggleJobDetail(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip> <div class="col" style="min-width:0;cursor:pointer" @click="toggleJobDetail(j)"><q-tooltip v-if="j.detail && !j.showDetail" max-width="360px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.detail }}</q-tooltip>
<div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-badge v-if="j.legacy" color="green-1" text-color="green-8" class="q-ml-xs" style="font-weight:700">F</q-badge> <q-icon name="info_outline" size="12px" class="text-grey-5" /></div> <div class="ellipsis text-weight-medium" style="font-size:13px">{{ j.subject }} <q-badge v-if="j.legacy" color="green-1" text-color="green-8" class="q-ml-xs" style="font-weight:700">F</q-badge> <q-icon name="info_outline" size="12px" class="text-grey-5" /></div>
<div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-warning"> · 🔒 RDV fixe</span><span v-if="j.customer"> · {{ j.customer }}</span></div> <div class="ellipsis text-grey-6" style="font-size:11px">{{ fmtHM(packedDay[i].startMin) }}{{ fmtHM(packedDay[i].endMin) }}<span v-if="j.locked" class="text-warning"> · 🔒 RDV fixe</span><template v-if="j.customer"> · <router-link v-if="j.customer_id" :to="'/clients/' + encodeURIComponent(j.customer_id)" class="erp-link" @click.stop>{{ j.customer }}</router-link><template v-else>{{ j.customer }}</template></template></div>
<div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j) && !j.legacy" class="loc-pick-link text-warning" @click.stop="openLocPicker(j)"> · ⚠ sans coords — 📍 situer</span><span v-else-if="!hasLL(j)" class="text-grey-5"> · sans coords (absent de la carte)</span></div> <div v-if="j.address || !hasLL(j)" class="ellipsis" style="font-size:11px;color:#90a4ae"><template v-if="j.address"><q-icon name="place" size="11px" /> {{ j.address }}</template><span v-if="!hasLL(j) && !j.legacy" class="loc-pick-link text-warning" @click.stop="openLocPicker(j)"> · ⚠ sans coords — 📍 situer</span><span v-else-if="!hasLL(j)" class="text-grey-5"> · sans coords (absent de la carte)</span></div>
</div> </div>
<template v-if="!j.legacy"> <template v-if="!j.legacy">
@ -1950,7 +1953,7 @@ import { useSSE, sendSmsViaHub } from 'src/composables/useSSE'
import { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes import { installMaplibre, basemapStyle, createPlanMap } from 'src/config/basemap' // fond de carte MapLibre/OSM auto-hébergé (plus de Mapbox, plus de logo) + init commune des cartes
import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>) import { legacyDeptColor, heatColor } from 'src/composables/useHelpers' // coloriage par type « comme legacy » (partagé) + heatColor (source unique, aussi <OccupancyStrip>)
import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur) import { useUserPrefs } from 'src/composables/useUserPrefs' // préférences d'affichage par utilisateur (serveur)
import { relTime, initials } from 'src/composables/useFormatters' // temps relatif + initiales (source unique, ex-locales dé-dupliquées) import { relTime, initials, erpLink } from 'src/composables/useFormatters' // temps relatif + initiales + lien ERP (source unique, ex-locales dé-dupliquées)
import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations import { messageIdentity, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' // resolvers partagés + priorité (drapeau) réutilisée des conversations
import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) réutilisé sur les jobs (échéance = création + résolution) import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (politiques éditables) réutilisé sur les jobs (échéance = création + résolution)
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
@ -2371,7 +2374,8 @@ function exportGpx (techId) {
window.open(roster.gpxUrl(techId, from, to), '_blank') window.open(roster.gpxUrl(techId, from, to), '_blank')
} }
// Détails d'un job : double-clic sur un bloc grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour. // Détails d'un job : double-clic sur un bloc grand volet DROIT (billet + commentaires) ; simple clic = éditeur de jour.
const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', address: '', skill: '', skills: [], time: '', durH: 1, detail: '', lid: null, iso: '', dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, assignTech: null, geofence: null, status: '' }) const jobDetail = reactive({ open: false, name: '', subject: '', customer: '', customerId: '', address: '', skill: '', skills: [], time: '', durH: 1, detail: '', lid: null, iso: '', dept: '', techId: '', techName: '', lat: null, lon: null, loading: false, thread: null, canTeam: false, team: [], teamLoading: false, teamAdd: null, assignTech: null, geofence: null, status: '' })
function openExternal (url) { if (url) window.open(url, '_blank', 'noopener') } // ouvre le doctype dans ERPNext (chrome harmonisé avec DetailModal)
// Décode les entités HTML (« d&#039;équipement » « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés). // Décode les entités HTML (« d&#039;équipement » « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null const _deEntEl = typeof document !== 'undefined' ? document.createElement('textarea') : null
function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value } function deEnt (s) { if (!s || String(s).indexOf('&') < 0 || !_deEntEl) return s || ''; _deEntEl.innerHTML = String(s); return _deEntEl.value }
@ -2383,7 +2387,7 @@ async function openJobDetail (b, t) {
// legacy_detail/legacy_ticket_id) ouvert depuis une goutte/carrousel. On mappe les DEUX formes pour toujours montrer // legacy_detail/legacy_ticket_id) ouvert depuis une goutte/carrousel. On mappe les DEUX formes pour toujours montrer
// client, compétence, description et le fil du billet. // client, compétence, description et le fil du billet.
const lid = b.legacy_id || b.legacy_ticket_id || null const lid = b.legacy_id || b.legacy_ticket_id || null
Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || b.customer_name || ''), address: deEnt(b.address || b.service_location || ''), skill: b.skill || b.required_skill || '', skills: (Array.isArray(b.required_skills) && b.required_skills.length ? b.required_skills.filter(Boolean) : (b.skill || b.required_skill ? [b.skill || b.required_skill] : [])), time: (b.start ? (b.start + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : '')) : (b.scheduled_date ? fmtDueLabel(b.scheduled_date) : '')), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || b.legacy_detail || ''), lid, iso: b.scheduled_date || b.iso || (boardView.value === 'routes' ? routesDay.value : '') || '', dept: b.dept || b.legacy_dept || '', techId: (t && t.id) || b.assigned_tech || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : (b.latitude != null ? +b.latitude : null), lon: b.lon != null ? +b.lon : (b.longitude != null ? +b.longitude : null), loading: !!lid, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null, assignTech: null, status: b.status || '' }) Object.assign(jobDetail, { open: true, name: b.name || '', subject: deEnt(b.subject || b.name || 'Job'), customer: deEnt(b.customer || b.customer_name || ''), customerId: b.customer_id || '', address: deEnt(b.address || b.service_location || ''), skill: b.skill || b.required_skill || '', skills: (Array.isArray(b.required_skills) && b.required_skills.length ? b.required_skills.filter(Boolean) : (b.skill || b.required_skill ? [b.skill || b.required_skill] : [])), time: (b.start ? (b.start + (b.dur ? ' · ' + Math.round(b.dur * 10) / 10 + 'h' : '')) : (b.scheduled_date ? fmtDueLabel(b.scheduled_date) : '')), durH: Math.round((durH0 || 1) * 100) / 100, detail: deEnt(b.detail || b.legacy_detail || ''), lid, iso: b.scheduled_date || b.iso || (boardView.value === 'routes' ? routesDay.value : '') || '', dept: b.dept || b.legacy_dept || '', techId: (t && t.id) || b.assigned_tech || '', techName: (t && t.name) || '', lat: b.lat != null ? +b.lat : (b.latitude != null ? +b.latitude : null), lon: b.lon != null ? +b.lon : (b.longitude != null ? +b.longitude : null), loading: !!lid, thread: null, canTeam: !!(b.name && !b.legacy), team: [], teamLoading: false, teamAdd: null, assignTech: null, status: b.status || '' })
// Géofencing (suivi façon colis) : timeline En route Arrivé Reparti, non bloquant. // Géofencing (suivi façon colis) : timeline En route Arrivé Reparti, non bloquant.
jobDetail.geofence = null jobDetail.geofence = null
if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {}) if (b.name) roster.jobGeofence(b.name).then(g => { if (jobDetail.name === b.name) jobDetail.geofence = g }).catch(() => {})
@ -5207,8 +5211,9 @@ const routesDay = ref(null)
// Sélecteur de jour (Tournées) = bande d'occupation sur 2 SEMAINES (14 j depuis le lundi affiché) par défaut. // Sélecteur de jour (Tournées) = bande d'occupation sur 2 SEMAINES (14 j depuis le lundi affiché) par défaut.
// Données via /roster/capacity (14 j) DÉCOUPLÉ de la fenêtre de charge du grid (7 j) barres réelles même en semaine 2. // Données via /roster/capacity (14 j) DÉCOUPLÉ de la fenêtre de charge du grid (7 j) barres réelles même en semaine 2.
const routeCapacity = ref({}) const routeCapacity = ref({})
async function loadRouteCapacity () { try { const r = await roster.getCapacity(start.value, 14); routeCapacity.value = r.capacity || {} } catch (e) { routeCapacity.value = {} } } // Dénominateur FILTRÉ par la/les compétence(s) choisie(s) (mêmes chips que la timeline) cohérent avec l'affichage.
watch([boardView, start], () => { if (boardView.value === 'routes') loadRouteCapacity() }, { immediate: true }) async function loadRouteCapacity () { try { const r = await roster.getCapacity(start.value, 14, skillFilter.value); routeCapacity.value = r.capacity || {} } catch (e) { routeCapacity.value = {} } }
watch([boardView, start, skillFilter], () => { if (boardView.value === 'routes') loadRouteCapacity() }, { immediate: true, deep: true })
const routeStripDays = computed(() => { const routeStripDays = computed(() => {
const out = [] const out = []
for (let i = 0; i < 14; i++) { for (let i = 0; i < 14; i++) {

View File

@ -1,6 +1,9 @@
'use strict' 'use strict'
const cfg = require('./config') const cfg = require('./config')
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers') 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')
// Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement). // 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)) const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String))
@ -163,7 +166,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
]) ])
if (techRes.status !== 200) throw new Error('Failed to fetch technicians') 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). // 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 hasSkill = (t) => techHasSkill(t.skills || t._user_tags, wantSkill)
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 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)) })()
// Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability. // Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability.
const vacBy = {} const vacBy = {}
@ -322,7 +325,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`), 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') 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 hasSkill = (t) => techHasSkill(t.skills || t._user_tags, wantSkill)
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 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 || []) : [] const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster). // Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).

View File

@ -675,18 +675,9 @@ function skillForJob (job) {
} }
// Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy. // Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy.
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags). // Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
function deptToSkill (txt) { // SOURCE UNIQUE : la logique vit dans lib/skill-resolver.js (partagée avec le résolveur raison→compétence
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '') // et le prédicat de disponibilité) ; ré-exportée ici pour ne rien casser des appelants existants.
if (!d) return '' const { deptToSkill, techHasSkills } = require('./skill-resolver')
if (/teleph/.test(d)) return 'telephone'
if (/tele|televis/.test(d)) return 'tv'
if (/fusion|episs/.test(d)) return 'épissure'
if (/monteur|aerien/.test(d)) return 'monteur'
if (/netadmin|net admin/.test(d)) return 'netadmin'
if (/repar|desinstall/.test(d)) return 'réparation'
if (/install|fibre/.test(d)) return 'installation'
return ''
}
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »). // Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
// Batch : 1 seule requête sur Service Location pour tous les codes distincts. // Batch : 1 seule requête sur Service Location pour tous les codes distincts.
async function attachLocations (jobs) { async function attachLocations (jobs) {
@ -1151,7 +1142,7 @@ async function occupancyByTechDay (start, days) {
// Inclut Completed/Cancelled : la grille montre AUSSI le travail fait/annulé de la journée (trace du tech), // Inclut Completed/Cancelled : la grille montre AUSSI le travail fait/annulé de la journée (trace du tech),
// pas seulement le planifiable. (Filtrés par assigned_tech ci-dessous : les annulés « non assigné » n'apparaissent pas.) // pas seulement le planifiable. (Filtrés par assigned_tech ci-dessous : les annulés « non assigné » n'apparaissent pas.)
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'In Progress', 'Completed', 'Cancelled']]], filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'In Progress', 'Completed', 'Cancelled']]],
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end', 'status'], limit: 5000, fields: ['name', 'subject', 'customer', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end', 'status'], limit: 5000,
}) })
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
const occChars = readJobChar().items // table additive (1 lecture) → durée EFFECTIVE estimée par job const occChars = readJobChar().items // table additive (1 lecture) → durée EFFECTIVE estimée par job
@ -1172,7 +1163,7 @@ async function occupancyByTechDay (start, days) {
const s = j.start_time ? timeToH(j.start_time) : null const s = j.start_time ? timeToH(j.start_time) : null
if (s != null && !cancelled) o.blocks.push({ s, e: s + dur, skill, job: j.name, done }) // 1 bloc = 1 job, coloré par sa compétence if (s != null && !cancelled) o.blocks.push({ s, e: s + dur, skill, job: j.name, done }) // 1 bloc = 1 job, coloré par sa compétence
const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin }) o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
} }
// ── ÉQUIPE : reporte la charge des assistants ÉPINGLÉS sur LEUR propre lane (mirror) ── // ── ÉQUIPE : reporte la charge des assistants ÉPINGLÉS sur LEUR propre lane (mirror) ──
// 1 requête enfant UNIQUE (pas de N+1). Le lead reste compté via assigned_tech ci-dessus ; // 1 requête enfant UNIQUE (pas de N+1). Le lead reste compté via assigned_tech ci-dessus ;
@ -1197,7 +1188,7 @@ async function occupancyByTechDay (start, days) {
o.h += dur o.h += dur
const s = j.start_time ? timeToH(j.start_time) : null const s = j.start_time ? timeToH(j.start_time) : null
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name, assist: true }) if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name, assist: true })
o.jobs.push({ name: j.name, subject: '👥 ' + (j.subject || j.service_type || j.name), customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: Math.round(dur * 60), priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: '', actual_end: '', actual_min: null, assist: true, lead_tech: j.assigned_tech || '' }) o.jobs.push({ name: j.name, subject: '👥 ' + (j.subject || j.service_type || j.name), customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: Math.round(dur * 60), priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: '', actual_end: '', actual_min: null, assist: true, lead_tech: j.assigned_tech || '' })
} }
} }
// ordre = route_order manuel s'il existe, sinon priorité puis heure // ordre = route_order manuel s'il existe, sinon priorité puis heure
@ -1239,7 +1230,7 @@ const DAY_SEGMENTS = [
] ]
const segOverlap = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a)) const segOverlap = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
const r1 = (x) => Math.round(x * 10) / 10 const r1 = (x) => Math.round(x * 10) / 10
async function capacityByDay (start, days) { async function capacityByDay (start, days, skills = null) {
const dates = rangeDates(start, days) const dates = rangeDates(start, days)
const templates = await fetchTemplates() const templates = await fetchTemplates()
const tpl = {}; for (const t of templates) tpl[t.name] = { s: timeToH(t.start_time), e: timeToH(t.end_time) } const tpl = {}; for (const t of templates) tpl[t.name] = { s: timeToH(t.start_time), e: timeToH(t.end_time) }
@ -1247,7 +1238,11 @@ async function capacityByDay (start, days) {
// Capacité RÉELLE : un quart matérialisé subsiste même quand le tech est en congé/pause/archivé → on retranche ces // Capacité RÉELLE : un quart matérialisé subsiste même quand le tech est en congé/pause/archivé → on retranche ces
// (tech, jour) sinon le dénominateur gonfle (ex. « 24/150 » alors que plusieurs techs sont absents). Aligné sur le // (tech, jour) sinon le dénominateur gonfle (ex. « 24/150 » alors que plusieurs techs sont absents). Aligné sur le
// moteur de créneaux + le solveur (buildUnavailability = En pause + absence_from/until + Tech Availability approuvé). // moteur de créneaux + le solveur (buildUnavailability = En pause + absence_from/until + Tech Availability approuvé).
const techsActive = await fetchTechnicians() // exclut les techs archivés let techsActive = await fetchTechnicians() // exclut les techs archivés
// FILTRE COMPÉTENCE (optionnel) : le dénominateur ne compte QUE les techs qui ont la/les compétence(s) demandée(s).
// Ex. « installation » → seuls les installateurs comptent (fini « 150 h dispo » alors que la moitié ne peut pas installer).
const wantSkills = (Array.isArray(skills) ? skills : (skills ? [skills] : [])).map(s => String(s || '').trim()).filter(Boolean)
if (wantSkills.length) techsActive = techsActive.filter(t => techHasSkills(t.skills, wantSkills))
const validTech = new Set(techsActive.map(t => t.id)) const validTech = new Set(techsActive.map(t => t.id))
const unavail = await buildUnavailability(techsActive, dates) const unavail = await buildUnavailability(techsActive, dates)
const isUnavailable = (tech, date) => !validTech.has(tech) || (unavail[tech] && unavail[tech].has(date)) const isUnavailable = (tech, date) => !validTech.has(tech) || (unavail[tech] && unavail[tech].has(date))
@ -1267,6 +1262,10 @@ async function capacityByDay (start, days) {
} }
for (const j of jobs) { for (const j of jobs) {
const o = out[j.scheduled_date]; if (!o) continue const o = out[j.scheduled_date]; if (!o) continue
// Sous filtre compétence : ne compter que ce qui occupe VRAIMENT un tech qualifié (assigné à un tech du dénominateur).
// On ignore les jobs assignés à un tech non qualifié + les jobs non placés (compétence inconnue ici) → used/due
// restent cohérents avec la capacité réduite (sinon due_h > cap_h ⇒ « surbooké » faux systématique).
if (wantSkills.length && (!j.assigned_tech || !validTech.has(j.assigned_tech))) continue
const dur = Number(j.duration_h) || 0 const dur = Number(j.duration_h) || 0
o.due_h += dur; o.jobs_due++ o.due_h += dur; o.jobs_due++
const sh = j.start_time ? timeToH(j.start_time) : null const sh = j.start_time ? timeToH(j.start_time) : null
@ -1333,7 +1332,15 @@ async function handle (req, res, method, path, url) {
} }
if (path === '/roster/capacity' && method === 'GET') { // dispo restante par jour × segment (AM/PM/Soir) if (path === '/roster/capacity' && method === 'GET') { // dispo restante par jour × segment (AM/PM/Soir)
if (!start) return json(res, 400, { error: 'start requis' }) if (!start) return json(res, 400, { error: 'start requis' })
return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS }) // ?skill=installation (ou ?skill=a,b) → dénominateur filtré : seuls les techs qui ont la/les compétence(s).
const skills = (qs.get('skill') || '').split(',').map(s => s.trim()).filter(Boolean)
return json(res, 200, { capacity: await capacityByDay(start, days, skills), segments: DAY_SEGMENTS, skill: skills })
}
// Résolveur « raison → compétence(s) » — appelé par <AvailabilityByReason> (ticket / courriel / fiche client).
// Body : { text?, department?, jobType?, useAI? } → { skills, primary, confidence, source, reason }.
if (path === '/roster/resolve-skills' && method === 'POST') {
const b = await parseBody(req)
return json(res, 200, await require('./skill-resolver').resolveSkills(b || {}))
} }
// Proxys OSRM pour le SPA (points = [[lon,lat],…]) — remplacent Mapbox Matrix/Directions (payants) par l'OSRM auto-hébergé. // Proxys OSRM pour le SPA (points = [[lon,lat],…]) — remplacent Mapbox Matrix/Directions (payants) par l'OSRM auto-hébergé.
if (path === '/roster/osrm-table' && method === 'POST') { if (path === '/roster/osrm-table' && method === 'POST') {

View File

@ -0,0 +1,140 @@
// ── Résolveur « raison → compétence(s) requises » — SOURCE UNIQUE ─────────────
// Pont réutilisable entre TROIS systèmes qui, jusqu'ici, ne se parlaient pas :
// 1. la RAISON d'une demande (chip de département, type de job, ou texte dicté) ;
// 2. la/les COMPÉTENCE(S) réelle(s) d'un technicien (installation, tv, réparation…) ;
// 3. la DISPONIBILITÉ filtrée par compétence (dénominateur des bandes d'occupation).
//
// Appelable de PARTOUT (ticket, courriel, fiche client) via le composant front
// <AvailabilityByReason> et l'endpoint /roster/resolve-skills. Un seul endroit à
// éditer pour faire évoluer la correspondance raison→compétence.
//
// N'importe RIEN de roster.js/dispatch.js (pour éviter tout cycle) — au contraire,
// roster.js ré-exporte `deptToSkill` d'ici pour ne pas dupliquer la logique.
const cfg = require('./config')
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']
function normSkill (s) { return String(s || '').trim().toLowerCase() }
function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') }
// ── Prédicat PARTAGÉ « ce technicien a-t-il la compétence ? » ──────────────────
// Sémantique IDENTIQUE à celle qui vivait (dupliquée) dans dispatch.js techOccupancy
// + suggestSlots : match exact OU inclusion (≥3 car.) tolérant aux variantes.
// techSkills accepte un TABLEAU (fetchTechnicians) OU une CSV brute (champ ERP / _user_tags).
function techHasSkill (techSkills, want) {
const w = normSkill(want)
if (!w) return true
const list = Array.isArray(techSkills) ? techSkills : String(techSkills || '').split(/[,;]/)
return list.some(s => {
s = normSkill(s)
return s && (s === w || (s.length >= 3 && (s.includes(w) || w.includes(s))))
})
}
// Plusieurs compétences requises → le tech doit les avoir TOUTES (aligné sur techCovers du solveur).
function techHasSkills (techSkills, wants) {
const ws = (Array.isArray(wants) ? wants : [wants]).map(normSkill).filter(Boolean)
if (!ws.length) return true
return ws.every(w => techHasSkill(techSkills, w))
}
// ── Repli déterministe : texte/département legacy → UNE compétence ─────────────
// (déplacé de roster.js — logique pure, réutilisée partout ; roster.js ré-exporte.)
function deptToSkill (txt) {
const d = stripAccents(txt).toLowerCase()
if (!d) return ''
if (/teleph/.test(d)) return 'telephone'
if (/tele|televis/.test(d)) return 'tv'
if (/fusion|episs/.test(d)) return 'épissure'
if (/monteur|aerien/.test(d)) return 'monteur'
if (/netadmin|net admin/.test(d)) return 'netadmin'
if (/repar|desinstall/.test(d)) return 'réparation'
if (/install|fibre/.test(d)) return 'installation'
return ''
}
// ── Pont DÉPARTEMENT/CATÉGORIE → compétence(s) ────────────────────────────────
// Clés = catégories de departments.js (SPA) ET valeurs CAT de categories.js (hub).
// [] = aucune compétence terrain (traité à distance / au bureau) → dénominateur = tous les techs.
const DEPARTMENT_SKILLS = {
Installation: ['installation'],
'Installation Fibre': ['installation'],
Réparation: ['réparation'],
Reparation: ['réparation'],
Télévision: ['tv'],
Television: ['tv'],
Téléphonie: ['telephone'],
Telephonie: ['telephone'],
Monteur: ['monteur'],
Épissure: ['épissure'],
Fusionneur: ['épissure'],
'Net Admin': ['netadmin'],
Support: [], // dépannage à distance → n'importe quel agent
Facturation: [],
Commercial: [], // vente / bureau
Vente: [],
Autre: [],
}
function deptSkills (dep) {
if (!dep) return null
if (Object.prototype.hasOwnProperty.call(DEPARTMENT_SKILLS, dep)) return DEPARTMENT_SKILLS[dep]
// tolérant aux accents/casse (« telephonie » → « Téléphonie »)
const key = Object.keys(DEPARTMENT_SKILLS).find(k => stripAccents(k).toLowerCase() === stripAccents(dep).toLowerCase())
return key ? DEPARTMENT_SKILLS[key] : null
}
// ── Chemin IA : texte libre dicté → compétence(s) ─────────────────────────────
// Réutilise le PATRON de inbox-triage.classifyEmail : JSON strict + reasoningEffort:'none'
// (sinon gemini « pense » et tronque le JSON → parse échoue silencieusement). Focalisé sur
// LA compétence (pas de match client), contraint au vocabulaire réel des techs.
async function aiSkills (text) {
if (!cfg.AI_API_KEY) return null
const sys = 'Tu assignes la ou les COMPÉTENCE(S) technique(s) requises pour traiter la demande d\'un client d\'un fournisseur Internet/télécom par fibre (TARGO / Gigafibre). ' +
'Choisis UNIQUEMENT parmi cette liste : ' + SKILL_VOCAB.join(', ') + '. ' +
'Un simple dépannage à distance, une question de facturation ou une vente ne requièrent AUCUNE compétence terrain → réponds "skills":[]. ' +
'Réponds UNIQUEMENT en JSON, sans texte autour : {"skills":["…"],"reason":"une courte phrase en français"}.'
try {
const out = await require('./ai').chat({ task: 'triage', maxTokens: 150, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: String(text || '').slice(0, 900) }] })
const j = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0])
const skills = (Array.isArray(j.skills) ? j.skills : []).map(normSkill).filter(s => SKILL_VOCAB.includes(s))
return { skills, reason: String(j.reason || '').slice(0, 200) }
} catch (e) { log('aiSkills error:', e.message); return null }
}
// ── Résolveur unifié ──────────────────────────────────────────────────────────
// resolveSkills({ text, department, jobType, useAI }) → { skills, primary, confidence, source, department, reason }
// Priorité : chip/département explicite (sûr) > type de job / regex sur le texte (moyen) > IA sur texte libre (moyen).
// confidence : 'high' (chip) · 'medium' (règle/IA) · 'low' (rien trouvé → aucune compétence, tous les techs).
async function resolveSkills ({ text = '', department = '', jobType = '', useAI = false } = {}) {
// 1. Département / chip explicite = intention claire de l'agent.
const ds = deptSkills(department)
if (ds) return { skills: ds, primary: ds[0] || '', confidence: 'high', source: 'chip', department, reason: '' }
// 2. Type de job explicite (Installation/Réparation…), puis regex sur le texte.
const rule = deptToSkill(jobType) || deptToSkill(text)
if (rule) return { skills: [rule], primary: rule, confidence: 'medium', source: 'rule', department, reason: '' }
// 3. IA sur le texte dicté (seulement si demandé ET clé dispo).
if (useAI && String(text || '').trim().length >= 4) {
const ai = await aiSkills(text)
if (ai && ai.skills.length) return { skills: ai.skills, primary: ai.skills[0], confidence: 'medium', source: 'ai', department, reason: ai.reason }
if (ai) return { skills: [], primary: '', confidence: 'low', source: 'ai', department, reason: ai.reason }
}
// 4. Rien de fiable → aucune compétence (le dénominateur reste « tous les techs »).
return { skills: [], primary: '', confidence: 'low', source: 'none', department, reason: '' }
}
module.exports = {
SKILL_VOCAB,
normSkill,
techHasSkill,
techHasSkills,
deptToSkill,
deptSkills,
DEPARTMENT_SKILLS,
aiSkills,
resolveSkills,
}