Compare commits

...

2 Commits

Author SHA1 Message Date
louispaulb
893ac8cd4a Déclutter: primitives réutilisables + palette ⌘K + Planif épurée
Ajoute 3 primitives partagées pour standardiser le désencombrement
(philosophie 3 paliers : coup d'œil → survol → détail) :
- OverflowMenu.vue : « ⋮ » standardisé (une action primaire visible,
  le secondaire dans le menu).
- DisclosureSection.vue : section repliée par défaut (résumé + corps
  révélé), état persisté par utilisateur (useUserPrefs).
- CommandPalette.vue + useCommandPalette.js : palette globale ⌘K
  (actions rapides + pages + actions contextuelles de page + recherche
  clients/équipe), registre d'actions par page = escape hatch.

Câblage MainLayout : palette montée + raccourci Cmd/Ctrl+K + indice ⌘K
dans la recherche desktop.

Adoptions :
- RapportsPage : PageHeader + stubs « Opérations » repliés + rapports
  enregistrés dans la palette.
- DashboardPage : bloc admin replié (DisclosureSection) + PPA rétrogradé
  dans un OverflowMenu (anti clic accidentel) + actions dans la palette.
- PlanificationPage : le panneau « Jobs à assigner » n'ouvre plus au
  chargement ; alerte « N à assigner » par jour (clic → panneau filtré) ;
  undo/redo/refresh regroupés dans un OverflowMenu ; actions dans la palette.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 06:53:24 -04:00
louispaulb
f4a6d07a8b 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>
2026-07-08 18:33:30 -04:00
19 changed files with 1111 additions and 155 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 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}`)

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

@ -0,0 +1,212 @@
<template>
<!-- Palette de commandes globale (Cmd/Ctrl+K). Recherche : actions rapides + pages +
actions contextuelles de la page + clients/équipe (fuzzy via le hub).
C'est l'escape hatch qui permet de déclutterer : rien de caché n'est perdu. -->
<q-dialog v-model="open" @show="onShow" @hide="onHide" position="top" transition-show="fade" transition-hide="fade">
<q-card class="cmdp">
<div class="cmdp-input-row">
<q-icon name="search" size="22px" class="cmdp-search-icon" />
<input
ref="inputEl"
v-model="query"
class="cmdp-input"
type="text"
placeholder="Rechercher une action, une page, un client…"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="runHighlighted"
@keydown.esc.prevent="open = false"
/>
<span class="cmdp-hint">esc</span>
</div>
<q-scroll-area class="cmdp-results" :style="{ height: resultsHeight }">
<template v-if="visible.length">
<template v-for="(grp, gi) in grouped" :key="grp.label">
<div class="cmdp-group-label">{{ grp.label }}</div>
<div
v-for="it in grp.items" :key="it._key"
class="cmdp-item" :class="{ 'is-active': it._idx === highlight }"
@mousemove="highlight = it._idx"
@click="runItem(it)"
>
<q-icon :name="it.icon || 'chevron_right'" size="20px" class="cmdp-item-icon" :style="it.color ? { color: it.color } : null" />
<div class="cmdp-item-body">
<div class="cmdp-item-label">{{ it.label }}</div>
<div v-if="it.caption" class="cmdp-item-caption">{{ it.caption }}</div>
</div>
<span v-if="it.typeLabel" class="cmdp-item-type">{{ it.typeLabel }}</span>
</div>
<q-separator v-if="gi < grouped.length - 1" spaced />
</template>
</template>
<div v-else class="cmdp-empty">
<q-spinner v-if="searching" size="20px" />
<span v-else>Aucun résultat pour « {{ query }} »</span>
</div>
</q-scroll-area>
<div class="cmdp-footer">
<span><kbd></kbd><kbd></kbd> naviguer</span>
<span><kbd></kbd> ouvrir</span>
<span><kbd></kbd><kbd>K</kbd> palette</span>
</div>
</q-card>
</q-dialog>
</template>
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { HUB_URL } from 'src/config/hub'
import { navItems as allNavItems } from 'src/config/nav'
import { usePermissions } from 'src/composables/usePermissions'
import { useConversations } from 'src/composables/useConversations'
import { useCreateSignal } from 'src/composables/useCreateSignal'
import { useCommandPalette } from 'src/composables/useCommandPalette'
const route = useRoute()
const router = useRouter()
const { can } = usePermissions()
const { newDialogOpen, newDialogChannel, openComposeDraft, panelOpen } = useConversations()
const { requestCreate } = useCreateSignal()
const { open, contextActions } = useCommandPalette()
const inputEl = ref(null)
const query = ref('')
const highlight = ref(0)
const searching = ref(false)
const entityResults = ref([])
let debounce = null
// Actions rapides intégrées (miroir du FAB « créer rapide »)
const quickActions = computed(() => {
const a = []
if (can('view_clients')) {
a.push({ id: 'create', label: 'Créer…', caption: 'tâche · intervention · soumission', icon: 'add_box', color: '#21ba45', group: 'Actions', keywords: 'nouveau job ticket devis creer', run: () => { requestCreate(); if (!route.path.startsWith('/planification')) router.push('/planification') } })
a.push({ id: 'email', label: 'Nouveau courriel', icon: 'mail', color: '#c10015', group: 'Actions', keywords: 'email mail message ecrire', run: () => { newDialogChannel.value = 'email'; newDialogOpen.value = true } })
a.push({ id: 'sms', label: 'Nouveau texto', icon: 'sms', color: '#21a56a', group: 'Actions', keywords: 'sms texto message', run: () => { newDialogChannel.value = 'sms'; newDialogOpen.value = true } })
a.push({ id: 'comms', label: 'Ouvrir les communications', icon: 'forum', group: 'Actions', keywords: 'boite inbox conversations fils', run: () => { panelOpen.value = false; router.push('/communications') } })
}
return a
})
// Pages (nav filtrée par capacités)
const pageActions = computed(() =>
allNavItems
.filter(n => !n.requires || can(n.requires))
.map(n => ({ id: 'nav-' + n.path, label: n.label, icon: 'north_east', group: 'Aller à', keywords: n.path, run: () => router.push(n.path) })),
)
// Actions contextuelles enregistrées par la page courante
const pageContext = computed(() =>
contextActions().map((a, i) => ({ ...a, id: a.id || ('ctx-' + i), group: a.group || 'Sur cette page', icon: a.icon || 'bolt' })),
)
function matches (item, q) {
if (!q) return true
const hay = (item.label + ' ' + (item.caption || '') + ' ' + (item.keywords || '')).toLowerCase()
return q.toLowerCase().split(/\s+/).every(tok => hay.includes(tok))
}
// Items filtrés, dans l'ordre de collecte : contexte actions pages entités.
const filtered = computed(() => {
const q = query.value.trim()
const out = []
for (const it of pageContext.value) if (matches(it, q)) out.push(it)
for (const it of quickActions.value) if (matches(it, q)) out.push(it)
for (const it of pageActions.value) if (matches(it, q)) out.push(it)
for (const it of entityResults.value) out.push(it) // déjà filtrées côté serveur
return out
})
// Rang des groupes GLOBAUX ; tout groupe inconnu (= action de page) passe en tête (0).
const GLOBAL_RANK = { 'Actions': 1, 'Clients / Équipe': 2, 'Aller à': 3 }
// Groupé POUR L'AFFICHAGE, puis aplati dans CE MÊME ordre pour attribuer `_idx`
// la navigation clavier suit exactement l'ordre visuel (pas de saut).
const grouped = computed(() => {
const byGroup = {}; const order = []
for (const it of filtered.value) { if (!byGroup[it.group]) { byGroup[it.group] = []; order.push(it.group) } byGroup[it.group].push(it) }
order.sort((a, b) => (a in GLOBAL_RANK ? GLOBAL_RANK[a] : 0) - (b in GLOBAL_RANK ? GLOBAL_RANK[b] : 0)) // tri stable
let idx = 0; const groups = []
for (const label of order) {
const items = byGroup[label].map(it => { const _idx = idx++; return { ...it, _idx, _key: (it.id || it.route || it.label) + '#' + _idx } })
groups.push({ label, items })
}
return groups
})
// Liste plate dans l'ordre d'affichage (pour le clavier).
const visible = computed(() => grouped.value.flatMap(g => g.items))
const resultsHeight = computed(() => Math.min(420, Math.max(120, visible.value.length * 52 + 40)) + 'px')
// Recherche d'entités (clients + équipe), fuzzy via le hub (même endpoint que la topbar)
function onQuery () {
clearTimeout(debounce)
const q = query.value.trim()
highlight.value = 0
if (q.length < 2) { entityResults.value = []; searching.value = false; return }
searching.value = true
debounce = setTimeout(() => runEntitySearch(q), 250)
}
async function runEntitySearch (q) {
try {
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`)
const matchesArr = r.ok ? ((await r.json()).matches || []) : []
entityResults.value = matchesArr.slice(0, 8).map(m => m.kind === 'équipe'
? { id: 't-' + m.name, group: 'Clients / Équipe', typeLabel: 'Équipe', icon: 'groups', label: m.customer_name || m.name, caption: 'Membre de léquipe · ' + m.email, run: () => openTeam(m.email) }
: { id: 'c-' + m.name, group: 'Clients / Équipe', typeLabel: 'Client', icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person', label: m.customer_name || m.name, caption: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name, run: () => router.push('/clients/' + m.name) })
} catch { entityResults.value = [] }
searching.value = false
}
async function openTeam (email) {
if (route.path.startsWith('/communications/c/')) { await router.push('/communications'); await nextTick() }
openComposeDraft({ to: email, channel: 'email' })
}
// query relance la recherche entité
watch(query, onQuery)
// Clavier
function move (dir) {
const n = visible.value.length
if (!n) return
highlight.value = (highlight.value + dir + n) % n
}
function runHighlighted () {
const it = visible.value[highlight.value]
if (it) runItem(it)
}
function runItem (it) {
open.value = false
if (typeof it.run === 'function') it.run()
}
function onShow () {
query.value = ''
entityResults.value = []
highlight.value = 0
nextTick(() => inputEl.value && inputEl.value.focus())
}
function onHide () { query.value = ''; entityResults.value = [] }
</script>
<style scoped>
.cmdp { width: 640px; max-width: 92vw; margin-top: 8vh; border-radius: 14px; box-shadow: 0 24px 60px rgba(0,0,0,.28); overflow: hidden; background: var(--ops-surface, #fff); }
.cmdp-input-row { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid var(--ops-border); }
.cmdp-search-icon { color: var(--ops-text-muted); }
.cmdp-input { flex: 1; border: none; outline: none; background: transparent; font-size: 1.05rem; color: var(--ops-text); }
.cmdp-hint { font-size: 0.68rem; color: var(--ops-text-muted); border: 1px solid var(--ops-border); border-radius: 6px; padding: 1px 6px; text-transform: uppercase; }
.cmdp-results { max-height: 60vh; }
.cmdp-group-label { font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ops-text-muted); padding: 12px 16px 4px; }
.cmdp-item { display: flex; align-items: center; gap: 12px; padding: 9px 16px; cursor: pointer; }
.cmdp-item.is-active { background: var(--ops-bg-light); }
.cmdp-item-icon { color: var(--ops-text-muted); flex: 0 0 auto; }
.cmdp-item-body { flex: 1; min-width: 0; }
.cmdp-item-label { font-size: 0.94rem; color: var(--ops-text); font-weight: 550; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cmdp-item-caption { font-size: 0.78rem; color: var(--ops-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cmdp-item-type { font-size: 0.7rem; color: var(--ops-text-muted); flex: 0 0 auto; }
.cmdp-empty { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 32px; color: var(--ops-text-muted); font-size: 0.9rem; }
.cmdp-footer { display: flex; gap: 16px; padding: 8px 16px; border-top: 1px solid var(--ops-border); font-size: 0.72rem; color: var(--ops-text-muted); }
.cmdp-footer kbd { font-family: inherit; border: 1px solid var(--ops-border); border-radius: 5px; padding: 0 5px; margin-right: 2px; background: var(--ops-bg-light); }
</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 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('')

View File

@ -0,0 +1,71 @@
<template>
<!-- Section repliable « glance detail » : n'affiche que l'essentiel, révèle le reste au clic.
Standardise le pattern ad-hoc (tickets slice(0,5) + « Voir tout », « Plus d'options »).
- #summary : ligne compacte TOUJOURS visible (aperçu quand replié). Optionnel.
- défaut : contenu révélé quand ouvert.
- #actions : boutons à droite de l'en-tête (n'ouvrent/ferment pas la section).
État d'ouverture persisté PAR UTILISATEUR si `persist-key` fourni (cross-appareil via /prefs).
Usage :
<DisclosureSection title="Tickets" :count="tickets.length" persist-key="client.tickets">
<template #summary>{{ openCount }} ouverts · dernier {{ lastDate }}</template>
<TicketList :rows="tickets" />
</DisclosureSection> -->
<div class="disclosure" :class="{ 'is-open': open }">
<div class="ds-header" role="button" tabindex="0"
@click="toggle" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
<q-icon :name="open ? 'expand_more' : 'chevron_right'" size="22px" class="ds-chevron" />
<q-icon v-if="icon" :name="icon" size="18px" class="ds-icon" />
<span class="ds-title">{{ title }}</span>
<span v-if="count !== null && count !== undefined" class="ds-count">{{ count }}</span>
<div v-if="$slots.summary && !open" class="ds-summary"><slot name="summary" /></div>
<q-space />
<div v-if="$slots.actions" class="ds-actions" @click.stop><slot name="actions" /></div>
</div>
<q-slide-transition>
<div v-show="open" class="ds-body"><slot /></div>
</q-slide-transition>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useUserPrefs } from 'src/composables/useUserPrefs'
const props = defineProps({
title: { type: String, default: '' },
count: { type: [Number, String], default: null },
icon: { type: String, default: '' },
defaultOpen: { type: Boolean, default: false },
// Clé de persistance de l'état ouvert/fermé (namespace 'disclosure'). Absente = pas de persistance.
persistKey: { type: String, default: '' },
})
// Un seul namespace partagé pour toutes les sections repliables de l'app.
const { prefs, save } = props.persistKey ? useUserPrefs('disclosure', {}) : { prefs: null, save: null }
const open = ref(
props.persistKey && prefs && prefs.value[props.persistKey] !== undefined
? !!prefs.value[props.persistKey]
: props.defaultOpen,
)
function toggle () {
open.value = !open.value
if (props.persistKey && save) save({ [props.persistKey]: open.value })
}
</script>
<style scoped>
.disclosure { border: 1px solid var(--ops-border); border-radius: 12px; background: var(--ops-surface, #fff); margin-bottom: 12px; overflow: hidden; }
.ds-header { display: flex; align-items: center; gap: 8px; padding: 12px 14px; cursor: pointer; user-select: none; min-height: 48px; }
.ds-header:hover { background: var(--ops-bg-light); }
.ds-header:focus-visible { outline: 2px solid var(--ops-primary, #6366f1); outline-offset: -2px; }
.ds-chevron { color: var(--ops-text-muted); flex: 0 0 auto; }
.ds-icon { color: var(--ops-text-muted); flex: 0 0 auto; }
.ds-title { font-weight: 650; font-size: 0.98rem; color: var(--ops-text); flex: 0 0 auto; }
.ds-count { font-size: 0.72rem; font-weight: 600; color: var(--ops-text-muted); background: var(--ops-bg-light); border: 1px solid var(--ops-border); border-radius: 20px; padding: 1px 9px; font-variant-numeric: tabular-nums; flex: 0 0 auto; }
.ds-summary { font-size: 0.84rem; color: var(--ops-text-muted); margin-left: 6px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ds-actions { display: flex; gap: 6px; align-items: center; flex: 0 0 auto; }
.ds-body { padding: 4px 14px 14px; border-top: 1px solid var(--ops-border); }
@media (max-width: 599px) { .ds-summary { display: none; } }
</style>

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

@ -0,0 +1,79 @@
<template>
<!-- Menu de débordement « » standardisé : UNE action primaire visible dans la barre,
TOUT le secondaire ici. Kill le bloat des barres d'outils (Planif, Dispatch « Outils »).
Usage :
<OverflowMenu :groups="[
{ label:'Réglages', items:[{icon:'tune', label:'Compétences', action:openSkills}] },
{ items:[{icon:'refresh', label:'Rafraîchir', action:reload}] },
]" />
ou à plat : <OverflowMenu :items="[{icon:'refresh', label:'Rafraîchir', action:reload}]" />
Chaque item : { icon, label, caption?, color?, disabled?, action?(fn) | to?(route) }. -->
<q-btn v-bind="triggerProps" :label="label" :icon="icon" :aria-label="ariaLabel">
<q-tooltip v-if="tooltip">{{ tooltip }}</q-tooltip>
<q-menu anchor="bottom right" self="top right" auto-close>
<q-list dense style="min-width:220px">
<template v-for="(g, gi) in normalizedGroups" :key="gi">
<q-separator v-if="gi > 0 && g.label == null" spaced />
<q-item-label v-if="g.label" header class="om-header">{{ g.label }}</q-item-label>
<q-item
v-for="(it, ii) in g.items" :key="gi + '-' + ii"
clickable
:to="it.to || undefined"
:disable="!!it.disabled"
@click="run(it)"
>
<q-item-section v-if="it.icon" avatar>
<q-icon :name="it.icon" :color="it.color || undefined" size="20px" />
</q-item-section>
<q-item-section>
<q-item-label>{{ it.label }}</q-item-label>
<q-item-label v-if="it.caption" caption>{{ it.caption }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-list>
</q-menu>
</q-btn>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
// Soit des groupes (avec en-têtes + séparateurs), soit une liste à plat.
groups: { type: Array, default: null },
items: { type: Array, default: null },
// Apparence du déclencheur : par défaut, une puce ronde « » discrète.
label: { type: String, default: '' },
icon: { type: String, default: 'more_vert' },
tooltip: { type: String, default: 'Plus dactions' },
ariaLabel: { type: String, default: 'Plus dactions' },
flat: { type: Boolean, default: true },
dense: { type: Boolean, default: true },
round: { type: Boolean, default: true },
})
// Déclencheur : rond quand pas de label (), sinon bouton texte discret.
const triggerProps = computed(() => ({
flat: props.flat,
dense: props.dense,
round: props.round && !props.label,
noCaps: true,
}))
const normalizedGroups = computed(() => {
if (props.groups && props.groups.length) {
return props.groups.map(g => ({ label: g.label || null, items: (g.items || []).filter(Boolean) }))
}
return [{ label: null, items: (props.items || []).filter(Boolean) }]
})
function run (it) {
if (it.disabled) return
if (typeof it.action === 'function') it.action()
}
</script>
<style scoped>
.om-header { color: var(--ops-text-muted); font-size: 0.72rem; letter-spacing: 0.03em; text-transform: uppercase; font-weight: 700; padding-top: 8px; }
</style>

View File

@ -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é

View File

@ -0,0 +1,52 @@
import { ref, reactive, readonly } from 'vue'
// ─────────────────────────────────────────────────────────────────────────────
// Palette de commandes globale (Cmd/Ctrl+K) — l'« escape hatch » qui permet de
// DÉCLUTTERER les pages : toute action reléguée dans un ⋮ ou une DisclosureSection
// reste joignable en tapant. Une seule instance, montée dans MainLayout.
//
// const { open, openPalette, registerActions } = useCommandPalette()
//
// Une PAGE enregistre ses actions contextuelles au montage et les retire au démontage :
// onMounted(() => registerActions('planif', [
// { id:'publish', label:'Publier lhoraire', icon:'cloud_upload', group:'Planification', run: publishWeek },
// ]))
// onUnmounted(() => unregisterActions('planif'))
//
// Chaque action : { id, label, icon?, caption?, group?, keywords?, run() }.
// ─────────────────────────────────────────────────────────────────────────────
const open = ref(false)
const _sources = reactive({}) // sourceId → [actions]
function openPalette () { open.value = true }
function closePalette () { open.value = false }
function togglePalette () { open.value = !open.value }
// Enregistre (ou remplace) le lot d'actions d'une source contextuelle.
function registerActions (sourceId, actions) {
_sources[sourceId] = Array.isArray(actions) ? actions.filter(a => a && a.label && typeof a.run === 'function') : []
}
function unregisterActions (sourceId) {
delete _sources[sourceId]
}
// Toutes les actions contextuelles actuellement enregistrées, à plat.
function contextActions () {
const out = []
for (const id in _sources) for (const a of _sources[id]) out.push({ ...a, _source: id })
return out
}
export function useCommandPalette () {
return {
open,
openPalette,
closePalette,
togglePalette,
registerActions,
unregisterActions,
contextActions,
sources: readonly(_sources),
}
}

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.
* `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.

View File

@ -98,8 +98,11 @@
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
@update:model-value="onSearchInput" @blur="onSearchBlur">
<template #prepend><q-icon name="search" color="grey-6" /></template>
<template #append v-if="searchQuery">
<q-icon name="close" class="cursor-pointer" @click="clearSearch" />
<template #append>
<q-icon v-if="searchQuery" name="close" class="cursor-pointer" @click="clearSearch" />
<div v-else class="ops-kbd-hint cursor-pointer" @click="openPalette">
<q-tooltip>Palette de commandes (K)</q-tooltip>K
</div>
</template>
</q-input>
<div v-if="searchResults.length && searchDropdownOpen" class="ops-search-results ops-search-results-desktop">
@ -161,11 +164,14 @@
<!-- Global Flow Editor dialog (any page can open it via useFlowEditor) -->
<FlowEditorDialog v-if="can('manage_settings')" />
<!-- Palette de commandes globale (Cmd/Ctrl+K) : actions + pages + actions de page + clients/équipe -->
<CommandPalette v-if="can('view_clients')" />
</q-layout>
</template>
<script setup>
import { ref, computed, nextTick, watch, defineAsyncComponent } from 'vue'
import { ref, computed, nextTick, watch, defineAsyncComponent, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth'
@ -189,6 +195,8 @@ const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue'))
const CommandPalette = defineAsyncComponent(() => import('src/components/shared/CommandPalette.vue'))
import { useCommandPalette } from 'src/composables/useCommandPalette'
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper }
@ -205,6 +213,14 @@ const orchestratorOpen = ref(false)
const serviceStatusOpen = ref(false)
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
// Palette de commandes globale (Cmd/Ctrl+K) escape hatch pour tout ce qu'on déclutter
const { togglePalette, openPalette } = useCommandPalette()
function onGlobalKey (e) {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); togglePalette() }
}
onMounted(() => window.addEventListener('keydown', onGlobalKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKey))
const auth = useAuthStore()
const { can, isLoaded, userName } = usePermissions()
@ -357,6 +373,10 @@ function onSearchBlur () {
</script>
<style>
/* Indice ⌘K dans la barre de recherche desktop → ouvre la palette de commandes */
.ops-kbd-hint { font-size: 0.68rem; font-weight: 600; color: var(--ops-text-muted, #94a3b8); border: 1px solid var(--ops-border, #e2e8f0); border-radius: 6px; padding: 1px 6px; line-height: 1.4; white-space: nowrap; }
.ops-kbd-hint:hover { color: var(--ops-text, #334155); border-color: var(--ops-text-muted, #94a3b8); }
/* Menu en sous-menus (sidebar foncée) : entêtes de section + sous-items lisibles sur fond sombre */
.ops-sidebar .ops-sec-header { min-height: 38px; }
.ops-sidebar .ops-sec-header .q-item__label { color: rgba(255,255,255,.55); font-size: .72rem; letter-spacing: .03em; text-transform: uppercase; font-weight: 700; }

View File

@ -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>

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-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 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) {
const loc = primaryLocation()
createJobCtx.value = {

View File

@ -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 } })" />
@ -42,8 +47,10 @@
</div>
</div>
<!-- Admin controls -->
<div class="row q-col-gutter-md q-mb-lg">
<!-- Administration : contrôles secondaires (planificateur, facturation) repliés par défaut la vue « coup d'œil » (KPI + charge + activité) reste épurée. -->
<DisclosureSection title="Administration" icon="admin_panel_settings" persist-key="dash.admin" class="q-mb-lg">
<template #summary>Planificateur {{ schedulerEnabled ? 'actif' : 'désactivé' }}<template v-if="draftInvoiceCount"> · {{ draftInvoiceCount }} brouillon(s)</template></template>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6">
<div class="ops-card">
<div class="text-subtitle1 text-weight-bold q-mb-sm">Planificateur</div>
@ -105,16 +112,12 @@
>
<q-tooltip>Soumet les {{ draftInvoiceCount }} facture(s) en brouillon</q-tooltip>
</q-btn>
<q-btn
label="PPA"
color="positive"
icon="credit_score"
:loading="runningPPA"
dense no-caps
@click="confirmPPA"
>
<q-tooltip>Prélève les cartes enregistrées (confirmation requise)</q-tooltip>
</q-btn>
<q-space />
<!-- Action sensible (le PPA prélève immédiatement cf. incident double-charge) : reléguée dans un
pour éviter le clic accidentel, sans la retirer. -->
<OverflowMenu tooltip="Actions sensibles" :items="[
{ icon: 'credit_score', label: runningPPA ? 'PPA en cours…' : 'PPA — prélever les cartes', caption: 'Prélèvement immédiat · confirmation requise', color: 'positive', disabled: runningPPA, action: confirmPPA },
]" />
</div>
<div class="q-mt-xs q-gutter-xs" style="min-height:20px">
<span v-if="billingResult" class="text-caption" :class="billingResult.ok ? 'text-positive' : 'text-negative'">
@ -130,6 +133,7 @@
</div>
</div>
</div>
</DisclosureSection>
<!-- Recent activity -->
<div class="row q-col-gutter-md">
@ -144,7 +148,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 +178,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"> &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-section>
<q-item-section side>
@ -194,8 +198,9 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useQuasar } from 'quasar'
import { useRouter } from 'vue-router'
import { listDocs, countDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth'
import { getCapacity } from 'src/api/roster'
@ -204,8 +209,13 @@ 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 DisclosureSection from 'src/components/shared/DisclosureSection.vue'
import OverflowMenu from 'src/components/shared/OverflowMenu.vue'
import { useCommandPalette } from 'src/composables/useCommandPalette'
import { DEPARTMENTS } from 'src/config/departments'
const $q = useQuasar()
const router = useRouter()
const stats = ref([
{ label: 'Abonnés', value: '...', color: 'var(--ops-accent)', icon: 'people', to: '/clients', sub: '', subClass: '' },
@ -221,13 +231,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)
@ -441,6 +454,16 @@ async function runPPA () {
runningPPA.value = false
}
// Actions d'administration joignables via la palette (K), même repliées dans « Administration ».
const { registerActions, unregisterActions } = useCommandPalette()
registerActions('dashboard', [
{ id: 'sched-toggle', label: 'Basculer le planificateur', icon: 'schedule', group: 'Administration', keywords: 'scheduler activer desactiver', run: toggleScheduler },
{ id: 'billing-generate', label: 'Facturation — Générer', icon: 'receipt_long', group: 'Administration', keywords: 'facture recurrente billing generer', run: runBilling },
{ id: 'billing-submit', label: 'Facturation — Soumettre les brouillons', icon: 'publish', group: 'Administration', keywords: 'soumettre draft facture', run: submitDrafts },
{ id: 'billing-ppa', label: 'Facturation — Lancer le PPA', icon: 'credit_score', group: 'Administration', keywords: 'ppa prelevement carte', run: confirmPPA },
])
onUnmounted(() => unregisterActions('dashboard'))
onMounted(async () => {
fetchSchedulerStatus()
refreshDraftCount()

View File

@ -44,8 +44,6 @@
<q-btn dense flat icon="keyboard_double_arrow_right" @click="navWeek(1)"><q-tooltip>Semaine suivante</q-tooltip></q-btn>
</q-btn-group>
<q-input dense outlined type="date" v-model="boardDate" style="width:150px"><q-tooltip>{{ boardView === 'kanban' ? 'Jour affiché (mode Jour)' : 'Début de la fenêtre (mode Semaine)' }}</q-tooltip></q-input>
<q-btn dense flat round icon="undo" :disable="!history.length" @click="undo"><q-tooltip>Annuler (Ctrl+Z)</q-tooltip></q-btn>
<q-btn dense flat round icon="redo" :disable="!future.length" @click="redo"><q-tooltip>Rétablir (Ctrl+Shift+Z)</q-tooltip></q-btn>
<!-- #2 Journal des changements + état auto-save (plus d'alerte « non publié ») -->
<q-btn dense flat round :icon="autosaving ? 'cloud_sync' : 'history'" :color="autosaving ? 'primary' : ''"><q-tooltip>Journal des changements · {{ autosaving ? 'sauvegarde en cours…' : 'auto-sauvegardé' }}</q-tooltip>
<q-menu anchor="bottom middle" self="top middle"><q-list dense style="min-width:290px;max-width:380px">
@ -148,7 +146,12 @@
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn flat dense round icon="refresh" :loading="loading" @click="() => guard(loadWeek)" />
<!-- Utilitaires secondaires regroupés dans un (annuler/rétablir gardent leurs raccourcis Ctrl+Z / Ctrl+Shift+Z) -->
<OverflowMenu :items="[
{ icon: 'undo', label: 'Annuler', caption: 'Ctrl+Z', disabled: !history.length, action: undo },
{ icon: 'redo', label: 'Rétablir', caption: 'Ctrl+Shift+Z', disabled: !future.length, action: redo },
{ icon: 'refresh', label: 'Rafraîchir', action: () => guard(loadWeek) },
]" />
</div>
<!-- Rangée 2 : filtres + légende en popover DESKTOP/tablette uniquement (gt-sm) -->
@ -439,6 +442,11 @@
<th class="tech-col"><div class="row items-center no-wrap">Ressource<q-space /><q-btn v-if="hiddenCount" flat dense round size="xs" :icon="showHidden ? 'visibility' : 'visibility_off'" :color="showHidden ? 'primary' : 'grey-6'" @click="showHidden = !showHidden"><q-badge floating color="grey-7" style="top:-7px;right:6px">{{ hiddenCount }}</q-badge><q-tooltip>{{ showHidden ? 'Cacher les ressources masquées' : (hiddenCount + ' masquée(s) — afficher en grisé') }}</q-tooltip></q-btn></div></th>
<th v-for="(d, di) in dayList" :key="d.iso" class="clk" :class="{ weekend: d.weekend, holiday: isHoliday(d.iso) }" @click="maybeSelectCol(di)">
<div class="dow">{{ d.dow }}</div><div class="dnum">{{ d.dnum }}</div>
<!-- Alerte « jobs non assignés » du jour ouvre la liste filtrée sur ce jour (remplace l'ouverture auto du panneau au chargement). -->
<div v-if="(unassignedByDay[d.iso] || []).length" class="day-unassigned" @click.stop="openDayInPanel(d.iso)">
<q-icon name="warning" size="12px" /> {{ (unassignedByDay[d.iso] || []).length }} à assigner
<q-tooltip class="bg-grey-9">{{ (unassignedByDay[d.iso] || []).length }} job(s) non assigné(s) ce jour cliquer pour ouvrir la liste (recherche · ville · compétence)</q-tooltip>
</div>
<div v-if="visStat(d.iso).hours || estLoad(d.iso)" class="cap-strip" @click.stop>
<span class="hdr-vbar"><span class="hdr-vfill" :style="{ height: hdrFillH(d.iso) + '%', background: heatColor(hdrRatio(d.iso)) }"></span></span>
<span class="cap-seg load-cell" :class="loadClass(d.iso)">{{ estLoad(d.iso) }}/{{ visStat(d.iso).hours }}h
@ -1400,7 +1408,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 +1423,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 +1842,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 +1961,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
@ -1963,6 +1974,8 @@ import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' // module de tags/compétences PARTAGÉ (chips colorées, création + palette, niveaux) SOURCE UNIQUE : jobs ET techs
import RouteMap from 'src/components/shared/RouteMap.vue' // carte de tournées réutilisable (revue dispatch auto + onglet Tournées) : OSRM réel, arrêts numérotés, fitTo
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' // bande d'occupation réutilisable (sélecteur de jour Tournées + tableau de bord)
import OverflowMenu from 'src/components/shared/OverflowMenu.vue' // « » standardisé pour les actions secondaires (désencombrer la barre)
import { useCommandPalette } from 'src/composables/useCommandPalette' // registre d'actions contextuelles joignables via K même si retirées de la barre
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue' // création de job NATIVE OPS (work-order) Dispatch déprécié, tout ici
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » pré-remplit la création (tech+date+heure+adresse)
import QuoteWizard from 'src/components/shared/QuoteWizard.vue' // soumission : adresse+fibre+prospect (lead-first)
@ -2371,7 +2384,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&#039;é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 +2397,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 +5221,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++) {
@ -6148,8 +6163,20 @@ function onLegacyUpdate (data) {
}
const dispatchSSE = useSSE({ listeners: { 'legacy-update': onLegacyUpdate } })
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } if ($q.screen.lt.md) { try { await reloadPool() } catch (e) { /* */ } } else openAssignPanel(); /* mobile : charge le pool pour la liste « À assigner » (assignation au toucher), pas de panneau flottant ; desktop : panneau ouvert */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onMounted(async () => { loadLS(); document.addEventListener('keydown', onKey); document.addEventListener('mouseup', onUp); window.addEventListener('beforeunload', onUnload); try { await loadBase() } catch (e) { err(e) } if (route.query.day || route.query.skill) focusDay(route.query.day, route.query.skill); await loadWeek(); loadDispatchPolicy(); try { const _h = await roster.holidays(todayISO(), addDaysISO(todayISO(), 400)); statHolidays.value = _h.holidays || [] } catch (e) { /* fériés best-effort */ } try { const _p = await roster.holidayNotice(40); holNoticePreview.value = _p.holidays || [] } catch (e) { /* préavis best-effort */ } /* dépôt + domiciles techs (origine de tournée) */ try { const m = await roster.bookMeta(); jobTypes.value = m.service_types || []; skillByType.value = m.skill_by_type || {} } catch (e) { /* catégories de job pour suggestions */ } try { await reloadPool() } catch (e) { /* */ } /* charge le pool (compteurs badge + « N à assigner » par jour) SANS ouvrir le panneau flottant — il s'ouvre à la demande : bouton « À assigner » ou clic sur l'alerte d'un jour */ dispatchSSE.connect(['dispatch']); /* veille Legacy temps-réel */ _nowTimer = setInterval(() => { nowTick.value++ }, 60000) /* ligne « maintenant » du board */ })
onUnmounted(() => { document.removeEventListener('keydown', onKey); document.removeEventListener('mouseup', onUp); window.removeEventListener('beforeunload', onUnload); if (_nowTimer) clearInterval(_nowTimer); stopLivePositions(); if (_kbRO) _kbRO.disconnect() })
// Actions principales de la Planif joignables via la palette (K), même reléguées dans un menu.
const { registerActions, unregisterActions } = useCommandPalette()
registerActions('planif', [
{ id: 'planif-publish', label: 'Publier lhoraire', icon: 'cloud_upload', group: 'Planification', keywords: 'publier horaire publish', run: doPublish },
{ id: 'planif-suggest', label: 'Suggérer (répartition auto)', icon: 'auto_awesome', group: 'Planification', keywords: 'suggerer repartition auto solveur', run: openSuggest },
{ id: 'planif-generate', label: 'Générer lhoraire (semaine)', icon: 'event_note', group: 'Planification', keywords: 'generer horaire quarts shift', run: doGenerate },
{ id: 'planif-create', label: 'Créer une tâche / intervention', icon: 'add', group: 'Planification', keywords: 'creer job tache intervention soumission', run: () => { createChooser.value = true } },
{ id: 'planif-assign', label: 'Jobs à assigner', icon: 'assignment_ind', group: 'Planification', keywords: 'assigner pool repartir', run: openAssignPanel },
{ id: 'planif-leave', label: 'Congés / absences', icon: 'beach_access', group: 'Planification', keywords: 'conges absences vacances', run: () => { showLeave.value = true } },
])
onUnmounted(() => unregisterActions('planif'))
onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 flush le brouillon (attendu) en partant ; plus jamais d'« abandonner ? »
</script>
@ -6173,6 +6200,9 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro
.loc-results { max-height: 190px; overflow: auto; border-radius: 6px; }
.loc-pick-link { cursor: pointer; text-decoration: underline; font-weight: 600; }
/* Bande de capacité restante par jour (AM/PM/Soir) dans l'en-tête */
/* Alerte cliquable « N à assigner » dans l'en-tête de jour (grille) — ouvre la liste du jour */
.day-unassigned { display: inline-flex; align-items: center; gap: 3px; margin: 2px auto 0; padding: 1px 7px; font-size: 10px; font-weight: 700; color: #b45309; background: #fef3c7; border: 1px solid #fde68a; border-radius: 20px; cursor: pointer; line-height: 1.5; }
.day-unassigned:hover { background: #fde68a; }
.cap-strip { display: flex; gap: 3px; justify-content: center; align-items: center; margin-top: 2px; }
/* Barre verticale d'occupation (miroir de la bande mobile) dans l'en-tête desktop */
.hdr-vbar { width: 7px; height: 22px; background: #eef2f7; border-radius: 4px; overflow: hidden; display: inline-flex; align-items: flex-end; flex: 0 0 auto; }

View File

@ -1,8 +1,8 @@
<template>
<q-page padding>
<div class="text-h6 text-weight-bold q-mb-md">Rapports</div>
<PageHeader title="Rapports" :count="financeReports.length" />
<!-- Comptabilité section -->
<!-- Comptabilité section (rapports opérationnels) -->
<div class="text-subtitle2 text-grey-7 text-weight-bold q-mb-sm">Comptabilité & Finances</div>
<div class="row q-col-gutter-md q-mb-lg">
<div class="col-12 col-md-6 col-lg-4" v-for="report in financeReports" :key="report.title">
@ -16,24 +16,34 @@
</div>
</div>
<!-- Opérations section -->
<div class="text-subtitle2 text-grey-7 text-weight-bold q-mb-sm">Opérations</div>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4" v-for="report in opsReports" :key="report.title">
<div class="ops-card cursor-pointer" style="min-height:120px;opacity:0.6" @click="report.action">
<div class="row items-center q-mb-sm">
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
<q-badge class="q-ml-sm" color="grey-4" text-color="grey-7" label="Bientôt" />
<!-- Opérations : rapports pas encore prêts repliés par défaut (ne polluent pas la vue). -->
<DisclosureSection title="Opérations" icon="insights" persist-key="rapports.ops">
<template #summary>{{ opsReports.length }} rapports bientôt disponibles</template>
<div class="row q-col-gutter-md">
<div class="col-12 col-md-6 col-lg-4" v-for="report in opsReports" :key="report.title">
<div class="ops-card cursor-pointer" style="min-height:120px;opacity:0.6" @click="report.action">
<div class="row items-center q-mb-sm">
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
<q-badge class="q-ml-sm" color="grey-4" text-color="grey-7" label="Bientôt" />
</div>
<div class="text-caption text-grey-6">{{ report.description }}</div>
</div>
<div class="text-caption text-grey-6">{{ report.description }}</div>
</div>
</div>
</div>
</DisclosureSection>
</q-page>
</template>
<script setup>
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import PageHeader from 'src/components/shared/PageHeader.vue'
import DisclosureSection from 'src/components/shared/DisclosureSection.vue'
import { useCommandPalette } from 'src/composables/useCommandPalette'
const router = useRouter()
const financeReports = [
{
title: 'Explorateur de revenus',
@ -130,4 +140,18 @@ const opsReports = [
action: () => {},
},
]
// Chaque rapport devient joignable par la palette (K) l'index visuel reste épuré,
// mais rien n'est « caché » : taper le nom d'un rapport l'ouvre directement.
const { registerActions, unregisterActions } = useCommandPalette()
onMounted(() => registerActions('rapports', financeReports.map(r => ({
id: 'rapport-' + r.route,
label: r.title,
caption: 'Rapport',
icon: r.icon,
group: 'Rapports',
keywords: r.description,
run: () => router.push(r.route),
}))))
onUnmounted(() => unregisterActions('rapports'))
</script>

View File

@ -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).

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.
// 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') {

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,
}