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:
parent
6bf323b18e
commit
f4a6d07a8b
|
|
@ -34,8 +34,14 @@ export const listRequirements = (start, days = 7) => jget(`/roster/requirements?
|
|||
export const createRequirement = (r) => jpost('/roster/requirements', r)
|
||||
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}`)
|
||||
// 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}`)
|
||||
// Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) − jobs placés, + charge due.
|
||||
// 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 getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
|
||||
export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`)
|
||||
|
|
|
|||
176
apps/ops/src/components/shared/AvailabilityByReason.vue
Normal file
176
apps/ops/src/components/shared/AvailabilityByReason.vue
Normal 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>
|
||||
|
|
@ -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 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>
|
||||
<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-section>
|
||||
<q-item-section side top>
|
||||
|
|
@ -145,6 +145,9 @@
|
|||
<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-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-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>
|
||||
|
|
@ -1010,6 +1013,11 @@
|
|||
<!-- COURRIEL AGRANDI — iframe sandbox plein écran (composant extrait) -->
|
||||
<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) -->
|
||||
<AddToTaskDialog v-model="addTaskOpen" :source="activeTaskSrc || taskSource" />
|
||||
</template>
|
||||
|
|
@ -1030,6 +1038,7 @@ import { useAuthStore } from 'src/stores/auth'
|
|||
import { useRouter } from 'vue-router'
|
||||
import AddToTaskDialog from 'src/components/shared/AddToTaskDialog.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'
|
||||
|
||||
const $q = useQuasar()
|
||||
|
|
@ -1048,6 +1057,7 @@ const {
|
|||
} = useConversations()
|
||||
|
||||
// ── 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 replySendAs = ref('')
|
||||
const composeSendAs = ref('')
|
||||
|
|
|
|||
117
apps/ops/src/components/shared/OccupancyBands.vue
Normal file
117
apps/ops/src/components/shared/OccupancyBands.vue
Normal 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>
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
<div class="cust-banner q-mb-sm">
|
||||
<q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" />
|
||||
<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-tooltip>Délier le client</q-tooltip>
|
||||
</q-btn>
|
||||
|
|
@ -145,6 +145,11 @@
|
|||
<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>
|
||||
<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-btn-dropdown>
|
||||
</div>
|
||||
|
|
@ -279,6 +284,14 @@
|
|||
:existing-jobs="dispatchJobs"
|
||||
@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>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -301,6 +314,7 @@ import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
|||
import TaskNode from 'src/components/shared/TaskNode.vue'
|
||||
import TagEditor from 'src/components/shared/TagEditor.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({
|
||||
doc: { type: Object, required: true },
|
||||
|
|
@ -322,6 +336,8 @@ const senderFull = computed(() => ((userName.value || '').trim() || (authStore.u
|
|||
const sendingReply = ref(false)
|
||||
const showCreateDialog = 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(() => ({
|
||||
subject: props.doc?.subject || '',
|
||||
|
|
@ -330,7 +346,10 @@ const dispatchContext = computed(() => ({
|
|||
service_location: props.doc?.service_location || '',
|
||||
issue_type: props.doc?.issue_type || '',
|
||||
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é ──
|
||||
|
||||
|
|
|
|||
|
|
@ -5,23 +5,40 @@
|
|||
* 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).
|
||||
*
|
||||
* É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 = [
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
]
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Renvoie [{ category, queue, icon, color, keyword, score }]. Le 1er = le plus probable.
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
*/
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { suggestSlots, techOccupancy } from 'src/api/dispatch'
|
||||
import { suggestSlots } from 'src/api/dispatch'
|
||||
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({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
|
|
@ -34,21 +35,10 @@ const slots = ref([])
|
|||
const searched = ref(false)
|
||||
|
||||
// ── Occupation des ressources (bandes verticales) ────────────────────────────
|
||||
// Panneau dépliable : occupation par tech sur 7 jours, FILTRÉE par la compétence choisie → on voit qui a de la place
|
||||
// AVANT même de saisir l'adresse (l'occupation ne dépend que du skill + de la date, pas du lieu).
|
||||
// Panneau dépliable : la grille elle-même vit dans <OccupancyBands> (partagée). Ici on ne garde que
|
||||
// l'état du panneau + le dernier `occ` (pour la légende/caption). Le composant se recharge seul.
|
||||
const occOpen = ref(false)
|
||||
const occ = ref(null)
|
||||
const occLoading = ref(false)
|
||||
let occTimer = null
|
||||
async function loadOcc () {
|
||||
occLoading.value = true
|
||||
try { occ.value = await techOccupancy({ after_date: afterDate.value, days: 7, skill: skill.value }) }
|
||||
catch (e) { occ.value = null; Notify.create({ type: 'negative', message: 'Occupation indisponible : ' + (e.message || e), timeout: 3000 }) }
|
||||
finally { occLoading.value = false }
|
||||
}
|
||||
function scheduleOcc () { if (!occOpen.value) return; clearTimeout(occTimer); occTimer = setTimeout(loadOcc, 250) }
|
||||
watch(occOpen, (v) => { if (v && !occ.value) loadOcc() })
|
||||
watch([skill, afterDate], scheduleOcc) // re-filtre l'occupation quand la compétence ou la date change (si le panneau est ouvert)
|
||||
|
||||
// Reset à l'ouverture (formulaire propre à chaque fois) — puis pré-remplissage éventuel (adresse/coords/compétence du contexte appelant).
|
||||
watch(() => props.modelValue, (o) => {
|
||||
|
|
@ -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
|
||||
// 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() }
|
||||
const pct = (o) => (o == null ? '—' : Math.round(o * 100) + '%')
|
||||
const OFF_LABEL = { weekend: 'week-end', no_shift: 'aucun quart', absence: 'absent', vacation: 'congé' }
|
||||
function offLabel (c) { return OFF_LABEL[c.reason] || 'indisponible' }
|
||||
// Couleur d'occupation (vert = libre → rouge = plein) pour la barre globale du tech.
|
||||
function occColor (o) { if (o == null) return '#cbd5e1'; if (o < 0.5) return '#22c55e'; if (o < 0.8) return '#f59e0b'; return '#ef4444' }
|
||||
function occShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) }
|
||||
function onAddrInput (v) { target.latitude = null; target.longitude = null; searchAddr(v) } // retape l'adresse → invalide les coords
|
||||
function onPickAddr (a) { selectAddr(a, target); addrResults.value = [] }
|
||||
|
|
@ -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'"
|
||||
:caption="occ ? (occ.techs.length + ' ressource' + (occ.techs.length > 1 ? 's' : '') + ' · dès ' + occShort(afterDate)) : 'Voir la charge par tech'">
|
||||
<div class="q-pt-xs">
|
||||
<div v-if="occLoading" class="text-center q-pa-sm"><q-spinner size="22px" color="primary" /></div>
|
||||
<div v-else-if="!occ || !occ.techs.length" class="text-caption text-grey-6 q-pa-sm">Aucune ressource{{ skill ? ' pour « ' + skill + ' »' : '' }} sur l'horizon.</div>
|
||||
<template v-else>
|
||||
<div class="occ-grid" :style="{ '--cols': occ.dates.length }">
|
||||
<!-- en-tête jours -->
|
||||
<div class="occ-name occ-corner"></div>
|
||||
<div v-for="d in occ.dates" :key="'h' + d" class="occ-dayh" :class="{ we: [0,6].includes(new Date(d + 'T12:00:00').getDay()) }">{{ occShort(d) }}</div>
|
||||
<!-- une ligne par tech -->
|
||||
<template v-for="t in occ.techs" :key="t.tech_id">
|
||||
<div class="occ-name">
|
||||
<div class="ellipsis text-weight-medium">{{ t.tech_name }}</div>
|
||||
<div class="occ-bar"><div class="occ-bar-fill" :style="{ width: ((t.occupancy || 0) * 100) + '%', background: occColor(t.occupancy) }"></div></div>
|
||||
<div class="occ-sub">{{ pct(t.occupancy) }} · {{ t.free_h }} h libre</div>
|
||||
</div>
|
||||
<div v-for="(c, i) in t.days" :key="t.tech_id + i" class="occ-cell" @click="onOccDay(c)">
|
||||
<div v-if="c.off" class="occ-strip off"><q-tooltip>{{ occShort(c.date) }} · {{ offLabel(c) }}</q-tooltip></div>
|
||||
<div v-else class="occ-strip" :class="{ clickable: true }">
|
||||
<div v-for="(b, bi) in c.blocks" :key="bi" class="occ-block" :class="{ reserved: b.reserved }"
|
||||
:style="{ top: (b.top * 100) + '%', height: Math.max(3, b.height * 100) + '%' }"></div>
|
||||
<q-tooltip>{{ occShort(c.date) }} · {{ c.shift_start }}–{{ c.shift_end }}<br>{{ pct(c.occupancy) }} occupé · {{ c.free_h }} h libre · {{ c.jobs }} job{{ c.jobs > 1 ? 's' : '' }}</q-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-md q-mt-xs occ-legend">
|
||||
<span><i class="occ-dot job"></i> occupé</span>
|
||||
<span><i class="occ-dot reserved"></i> réservé</span>
|
||||
<span><i class="occ-dot free"></i> libre</span>
|
||||
<span><i class="occ-dot offd"></i> pas de quart</span>
|
||||
<q-space /><span class="text-grey-6">clic sur un jour → cale la recherche</span>
|
||||
</div>
|
||||
</template>
|
||||
<OccupancyBands :skill="skill" :after-date="afterDate" :days="7" :active="occOpen" @day="onOccDay" @loaded="v => occ = v" />
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
|
|
@ -204,27 +158,5 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
|||
<style scoped>
|
||||
.ss-chip { display: inline-flex; align-items: center; padding: 3px 11px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12.5px; font-weight: 600; cursor: pointer; user-select: none; background: #fff; text-transform: capitalize; }
|
||||
.ss-addr-list { border-radius: 6px; max-height: 168px; overflow-y: auto; }
|
||||
|
||||
/* ── Occupation : grille tech × jours, chaque case = bande verticale (mini-timeline du quart) ── */
|
||||
.ss-occ-exp { border: 1px solid #e2e8f0; border-radius: 8px; }
|
||||
.occ-grid { display: grid; grid-template-columns: 118px repeat(var(--cols), 1fr); gap: 3px; align-items: stretch; max-height: 42vh; overflow: auto; padding: 2px; }
|
||||
.occ-corner { background: transparent; }
|
||||
.occ-dayh { font-size: 10px; text-align: center; color: #64748b; font-weight: 600; padding-bottom: 2px; text-transform: capitalize; }
|
||||
.occ-dayh.we { color: #b45309; }
|
||||
.occ-name { min-width: 0; padding: 2px 4px 2px 0; display: flex; flex-direction: column; justify-content: center; font-size: 11.5px; }
|
||||
.occ-name .ellipsis { max-width: 112px; }
|
||||
.occ-bar { height: 4px; background: #e5e7eb; border-radius: 3px; overflow: hidden; margin: 2px 0; }
|
||||
.occ-bar-fill { height: 100%; border-radius: 3px; transition: width .2s; }
|
||||
.occ-sub { font-size: 9.5px; color: #64748b; }
|
||||
.occ-cell { display: flex; }
|
||||
.occ-strip { position: relative; flex: 1; min-height: 46px; border-radius: 4px; background: #dcfce7; border: 1px solid #bbf7d0; overflow: hidden; cursor: pointer; }
|
||||
.occ-strip.off { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 4px, #e2e8f0 4px, #e2e8f0 8px); border-color: #e2e8f0; cursor: default; }
|
||||
.occ-block { position: absolute; left: 1px; right: 1px; background: #2563eb; border-radius: 2px; }
|
||||
.occ-block.reserved { background: #f59e0b; }
|
||||
.occ-legend { font-size: 10.5px; color: #475569; }
|
||||
.occ-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
|
||||
.occ-dot.job { background: #2563eb; }
|
||||
.occ-dot.reserved { background: #f59e0b; }
|
||||
.occ-dot.free { background: #dcfce7; border: 1px solid #bbf7d0; }
|
||||
.occ-dot.offd { background: #e2e8f0; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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-tooltip>Prendre RDV (réparation / installation) — pré-rempli avec l'adresse du client</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn dense flat size="sm" no-caps color="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-tooltip>Carte-cadeau : voir / copier le lien, éditer ou envoyer par courriel</q-tooltip>
|
||||
</q-btn>
|
||||
|
|
@ -593,6 +596,9 @@
|
|||
<!-- « Trouver un créneau » direct (appel / réparation) → crée un Dispatch Job lié au client -->
|
||||
<SuggestSlotsDialog v-model="findSlotOpen" :skills="slotSkills"
|
||||
:initial-address="findSlotAddr" :initial-skill="findSlotSkill" @select="onFindSlotSelected" />
|
||||
<!-- « 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"
|
||||
: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 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 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 CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue'
|
||||
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
||||
|
|
@ -1198,14 +1206,12 @@ async function loadJobs () {
|
|||
const findSlotOpen = ref(false)
|
||||
const findSlotAddr = ref('')
|
||||
const findSlotSkill = ref('')
|
||||
const availReasonOpen = ref(false)
|
||||
const createJobOpen = ref(false)
|
||||
const createJobCtx = ref({})
|
||||
const slotSkills = [
|
||||
{ skill: 'réparation', dur: 1, color: '#ef4444' },
|
||||
{ skill: 'installation', dur: 2, color: '#f59e0b' },
|
||||
{ skill: 'tv', dur: 1, color: '#8b5cf6' },
|
||||
{ skill: 'téléphonie', dur: 0.5, color: '#0ea5e9' },
|
||||
]
|
||||
// Chips de compétence dérivés de la SOURCE UNIQUE (config/departments.js) — plus de liste codée en dur.
|
||||
const SKILL_COLORS = { installation: '#f59e0b', 'réparation': '#ef4444', tv: '#8b5cf6', telephone: '#0ea5e9', 'épissure': '#10b981', monteur: '#6366f1' }
|
||||
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' }))
|
||||
function primaryLocation () {
|
||||
const list = (sortedLocations.value && sortedLocations.value.length) ? sortedLocations.value : locations.value
|
||||
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
|
||||
findSlotOpen.value = true
|
||||
}
|
||||
// « Dispo par motif » : pré-remplit l'adresse du lieu de service puis ouvre le widget raison→compétence→dispo.
|
||||
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) {
|
||||
const loc = primaryLocation()
|
||||
createJobCtx.value = {
|
||||
|
|
|
|||
|
|
@ -25,8 +25,13 @@
|
|||
<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>
|
||||
<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')" />
|
||||
</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">
|
||||
<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 } })" />
|
||||
|
|
@ -144,7 +149,7 @@
|
|||
</q-item-section>
|
||||
<q-item-section>
|
||||
<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 side>
|
||||
<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 caption>
|
||||
<span v-if="j.assigned_tech">{{ j.assigned_tech }}</span>
|
||||
<span v-if="j.customer"> · {{ j.customer }}</span>
|
||||
<template v-if="j.customer"> · <router-link :to="'/clients/' + encodeURIComponent(j.customer)" class="erp-link" @click.stop>{{ j.customer }}</router-link></template>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
|
|
@ -204,6 +209,7 @@ import { HUB_SSE_URL } from 'src/config/dispatch'
|
|||
import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
|
||||
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
|
||||
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
|
||||
import { DEPARTMENTS } from 'src/config/departments'
|
||||
|
||||
const $q = useQuasar()
|
||||
|
||||
|
|
@ -221,13 +227,16 @@ const todayJobs = ref([])
|
|||
// ── Charge de la semaine (bande d'occupation, réutilise /roster/capacity) ──
|
||||
const weekLoad = ref([])
|
||||
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 FR_DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||||
|
||||
async function loadWeek () {
|
||||
try {
|
||||
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
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const dt = new Date(mon); dt.setDate(mon.getDate() + i)
|
||||
|
|
|
|||
|
|
@ -1400,7 +1400,8 @@
|
|||
<q-icon name="assignment" color="primary" size="22px" class="q-mr-sm" />
|
||||
<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-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é).
|
||||
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"
|
||||
|
|
@ -1414,7 +1415,9 @@
|
|||
</template>
|
||||
</AssignmentField>
|
||||
</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-separator />
|
||||
<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>
|
||||
<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-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>
|
||||
<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 { 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 { 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 { 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
|
||||
|
|
@ -2371,7 +2374,8 @@ function exportGpx (techId) {
|
|||
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. ──
|
||||
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'équipement » → « d'équipement ») pour NORMALISER les détails affichés (sujets legacy encodés).
|
||||
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 }
|
||||
|
|
@ -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
|
||||
// client, compétence, description et le fil du billet.
|
||||
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.
|
||||
jobDetail.geofence = null
|
||||
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.
|
||||
// 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({})
|
||||
async function loadRouteCapacity () { try { const r = await roster.getCapacity(start.value, 14); routeCapacity.value = r.capacity || {} } catch (e) { routeCapacity.value = {} } }
|
||||
watch([boardView, start], () => { if (boardView.value === 'routes') loadRouteCapacity() }, { immediate: true })
|
||||
// Dénominateur FILTRÉ par la/les compétence(s) choisie(s) (mêmes chips que la timeline) → cohérent avec l'affichage.
|
||||
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 out = []
|
||||
for (let i = 0; i < 14; i++) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers')
|
||||
// Prédicat « ce tech a-t-il la compétence ? » PARTAGÉ (même sémantique que capacityByDay/solveur). Le champ ERP `skills`
|
||||
// (ou `_user_tags`) est une CSV → techHasSkill l'accepte telle quelle. SOURCE UNIQUE : lib/skill-resolver.js.
|
||||
const { techHasSkill } = require('./skill-resolver')
|
||||
// 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))
|
||||
|
||||
|
|
@ -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')
|
||||
// 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)) })()
|
||||
// Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability.
|
||||
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`),
|
||||
])
|
||||
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 allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
|
||||
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
|
||||
function deptToSkill (txt) {
|
||||
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
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 ''
|
||||
}
|
||||
// SOURCE UNIQUE : la logique vit dans lib/skill-resolver.js (partagée avec le résolveur raison→compétence
|
||||
// et le prédicat de disponibilité) ; ré-exportée ici pour ne rien casser des appelants existants.
|
||||
const { deptToSkill, techHasSkills } = require('./skill-resolver')
|
||||
// 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.
|
||||
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),
|
||||
// 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']]],
|
||||
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 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
|
||||
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
|
||||
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) ──
|
||||
// 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
|
||||
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 })
|
||||
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
|
||||
|
|
@ -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 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 templates = await fetchTemplates()
|
||||
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
|
||||
// (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é).
|
||||
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 unavail = await buildUnavailability(techsActive, dates)
|
||||
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) {
|
||||
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
|
||||
o.due_h += dur; o.jobs_due++
|
||||
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 (!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é.
|
||||
if (path === '/roster/osrm-table' && method === 'POST') {
|
||||
|
|
|
|||
140
services/targo-hub/lib/skill-resolver.js
Normal file
140
services/targo-hub/lib/skill-resolver.js
Normal 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,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user