Compare commits
2 Commits
6bf323b18e
...
893ac8cd4a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
893ac8cd4a | ||
|
|
f4a6d07a8b |
|
|
@ -34,8 +34,14 @@ export const listRequirements = (start, days = 7) => jget(`/roster/requirements?
|
|||
export const createRequirement = (r) => jpost('/roster/requirements', r)
|
||||
export const listAssignments = (start, days = 7) => jget(`/roster/assignments?start=${start}&days=${days}`)
|
||||
export const getCoverage = (start, days = 7) => jget(`/roster/coverage?start=${start}&days=${days}`)
|
||||
// Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) − jobs placés, + charge due
|
||||
export const getCapacity = (start, days = 7) => jget(`/roster/capacity?start=${start}&days=${days}`)
|
||||
// Dispo restante par jour × segment (AM/PM/Soir) : capacité (quarts) − jobs placés, + charge due.
|
||||
// skill (chaîne ou tableau) → dénominateur FILTRÉ : ne compte que les techs qui ont cette/ces compétence(s).
|
||||
export const getCapacity = (start, days = 7, skill = '') => {
|
||||
const s = (Array.isArray(skill) ? skill : (skill ? [skill] : [])).filter(Boolean)
|
||||
return jget(`/roster/capacity?start=${start}&days=${days}${s.length ? '&skill=' + encodeURIComponent(s.join(',')) : ''}`)
|
||||
}
|
||||
// Résolveur « raison → compétence(s) » : { text?, department?, jobType?, useAI? } → { skills, primary, confidence, source, reason }.
|
||||
export const resolveSkills = (body) => jpost('/roster/resolve-skills', body || {})
|
||||
export const getStats = (start, days = 7) => jget(`/roster/stats?start=${start}&days=${days}`)
|
||||
export const getOccupancy = (start, days = 7) => jget(`/roster/occupancy?start=${start}&days=${days}`)
|
||||
export const getAbsences = (start, days = 7) => jget(`/roster/absences?start=${start}&days=${days}`)
|
||||
|
|
|
|||
176
apps/ops/src/components/shared/AvailabilityByReason.vue
Normal file
176
apps/ops/src/components/shared/AvailabilityByReason.vue
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
<script setup>
|
||||
/**
|
||||
* AvailabilityByReason — module RÉUTILISABLE « raison → compétence → disponibilité ».
|
||||
*
|
||||
* Montable de PARTOUT (fiche client, détail de ticket, panneau de conversation/courriel) :
|
||||
* 1. RAISON — chips des motifs groupés par département (config/departments.js) OU « dictez la raison »
|
||||
* (texte libre → l'IA du hub évalue la/les compétence(s) probable(s)).
|
||||
* 2. COMPÉTENCE résolue → affichée + éditable.
|
||||
* 3. DISPONIBILITÉ — bandes d'occupation (<OccupancyBands>) dont le DÉNOMINATEUR ne compte que les
|
||||
* techs qualifiés (« 70 h installation » au lieu de « 150 h tous techs »).
|
||||
* 4. Bouton « Trouver un créneau » → émet 'book' (skills + durée + date + contexte) pour que le parent
|
||||
* ouvre SuggestSlotsDialog / UnifiedCreateModal pré-rempli.
|
||||
*
|
||||
* Le texte libre est pré-rempli depuis le contexte (corps du courriel / sujet du ticket) via `initialText`.
|
||||
*/
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { DEPARTMENTS, suggestDepartments } from 'src/config/departments'
|
||||
import { resolveSkills } from 'src/api/roster'
|
||||
import OccupancyBands from 'src/components/shared/OccupancyBands.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
initialText: { type: String, default: '' }, // corps courriel / sujet ticket → pré-remplit « dictez la raison »
|
||||
// Contexte optionnel repassé tel quel au parent lors du booking (fiche client / ticket).
|
||||
customerName: { type: String, default: '' },
|
||||
address: { type: String, default: '' },
|
||||
lat: { type: Number, default: null },
|
||||
lng: { type: Number, default: null },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'book'])
|
||||
|
||||
const today = () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
|
||||
const afterDate = ref(today())
|
||||
const reasonText = ref('')
|
||||
const dept = ref('') // département/motif choisi (chip) → confiance haute
|
||||
const resolved = ref(null) // { skills, primary, confidence, source, reason }
|
||||
const aiLoading = ref(false)
|
||||
const occ = ref(null) // dernier chargement des bandes → résumé du dénominateur
|
||||
|
||||
// Compétence(s) effective(s) : résolveur si présent, sinon skills du chip choisi.
|
||||
const skills = computed(() => {
|
||||
if (resolved.value && resolved.value.skills) return resolved.value.skills
|
||||
const d = DEPARTMENTS.find(x => x.category === dept.value)
|
||||
return d ? (d.skills || []) : []
|
||||
})
|
||||
const primarySkill = computed(() => skills.value[0] || '')
|
||||
const durH = computed(() => { const d = DEPARTMENTS.find(x => x.category === dept.value); return d ? (d.dur || 1) : 1 })
|
||||
|
||||
// Résumé du DÉNOMINATEUR ajusté (agrégé depuis les bandes) : heures libres + nb de techs qualifiés.
|
||||
const summary = computed(() => {
|
||||
const o = occ.value; if (!o || !o.techs) return null
|
||||
const free = o.techs.reduce((a, t) => a + (t.free_h || 0), 0)
|
||||
const shift = o.techs.reduce((a, t) => a + (t.shift_h || 0), 0)
|
||||
return { techs: o.techs.length, free_h: Math.round(free * 10) / 10, shift_h: Math.round(shift * 10) / 10 }
|
||||
})
|
||||
|
||||
// Chip cliqué = intention claire → compétence(s) du département (confiance haute), pas d'IA.
|
||||
function pickDept (d) {
|
||||
dept.value = d.category
|
||||
resolved.value = { skills: d.skills || [], primary: (d.skills || [])[0] || '', confidence: 'high', source: 'chip', reason: '' }
|
||||
}
|
||||
|
||||
// « Dictez la raison » : régex instantanée (chips surlignés) puis IA à la soumission.
|
||||
const suggested = computed(() => suggestDepartments(reasonText.value)) // départements probables (surlignage instantané)
|
||||
async function evaluateAI () {
|
||||
const text = reasonText.value.trim()
|
||||
if (text.length < 4) return
|
||||
aiLoading.value = true
|
||||
try {
|
||||
const r = await resolveSkills({ text, department: dept.value || '', useAI: true })
|
||||
resolved.value = r
|
||||
if (!dept.value && r.primary) { const d = DEPARTMENTS.find(x => (x.skills || []).includes(r.primary)); if (d) dept.value = d.category }
|
||||
if (!r.skills.length) Notify.create({ type: 'info', message: 'Aucune compétence terrain requise (traité à distance) — dispo = tous les techs.', timeout: 3500 })
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Évaluation IA indisponible : ' + (e.message || e) + ' — choisis un motif ci-dessus.', timeout: 4000 })
|
||||
} finally { aiLoading.value = false }
|
||||
}
|
||||
|
||||
function clearReason () { dept.value = ''; resolved.value = null; reasonText.value = '' }
|
||||
|
||||
function book () {
|
||||
emit('book', {
|
||||
skills: skills.value, primary: primarySkill.value, duration: durH.value, date: afterDate.value,
|
||||
customerName: props.customerName, address: props.address, lat: props.lat, lng: props.lng,
|
||||
})
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
// Réinitialise + pré-remplit à chaque ouverture.
|
||||
watch(() => props.modelValue, (o) => {
|
||||
if (!o) return
|
||||
afterDate.value = today(); dept.value = ''; resolved.value = null; occ.value = null
|
||||
reasonText.value = props.initialText || ''
|
||||
})
|
||||
|
||||
const confidenceLabel = { high: 'motif choisi', medium: 'évalué', low: 'incertain' }
|
||||
const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||||
function onOccDay (cell) { if (cell && !cell.off) afterDate.value = cell.date }
|
||||
function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) + '/' + iso.slice(5, 7) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<q-dialog :model-value="modelValue" @update:model-value="v => emit('update:modelValue', v)">
|
||||
<q-card style="width:560px;max-width:96vw">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<q-icon name="event_available" color="primary" size="24px" class="q-mr-sm" />
|
||||
<div class="text-h6">Disponibilité par motif</div>
|
||||
<q-space /><q-btn flat round dense icon="close" @click="emit('update:modelValue', false)" />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<!-- 1. Motifs (chips par département) -->
|
||||
<div class="text-caption text-grey-7">Motif de la demande</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
<q-chip v-for="d in DEPARTMENTS" :key="d.category" clickable dense
|
||||
:color="dept === d.category ? d.color : (suggested.some(s => s.category === d.category) ? 'amber-2' : 'grey-3')"
|
||||
:text-color="dept === d.category ? 'white' : 'grey-9'"
|
||||
:icon="d.icon" @click="pickDept(d)">
|
||||
{{ d.category }}
|
||||
<q-tooltip>{{ d.skills.length ? 'Compétence : ' + d.skills.join(', ') + ' · ' + d.dur + ' h par défaut' : 'À distance / bureau — aucune compétence terrain' }}</q-tooltip>
|
||||
</q-chip>
|
||||
</div>
|
||||
|
||||
<!-- 1b. Dictez la raison → IA -->
|
||||
<q-input dense outlined v-model="reasonText" type="textarea" autogrow :rows="1"
|
||||
label="…ou dictez la raison (l'IA évalue la compétence)" clearable @keyup.enter.exact="evaluateAI">
|
||||
<template #prepend><q-icon name="mic" /></template>
|
||||
<template #append>
|
||||
<q-btn flat dense no-caps color="primary" icon="auto_awesome" :loading="aiLoading"
|
||||
:disable="reasonText.trim().length < 4" label="Évaluer" @click="evaluateAI" />
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<!-- 2. Compétence résolue -->
|
||||
<div v-if="resolved" class="row items-center q-gutter-xs resolved-row">
|
||||
<q-icon :name="skills.length ? 'engineering' : 'support_agent'" size="18px" :color="skills.length ? 'teal-7' : 'blue-grey-5'" />
|
||||
<span v-if="skills.length" class="text-weight-medium">
|
||||
Compétence requise :
|
||||
<q-chip v-for="s in skills" :key="s" dense square color="teal-1" text-color="teal-9" class="q-ml-xs">{{ s }}</q-chip>
|
||||
</span>
|
||||
<span v-else class="text-blue-grey-7">Aucune compétence terrain — traité à distance (tous les agents).</span>
|
||||
<q-badge outline color="grey-6" class="q-ml-xs">{{ confidenceLabel[resolved.confidence] || resolved.confidence }}</q-badge>
|
||||
<q-btn flat dense round size="sm" icon="close" color="grey-6" @click="clearReason"><q-tooltip>Effacer le motif</q-tooltip></q-btn>
|
||||
<div v-if="resolved.reason" class="full-width text-caption text-grey-6 q-pl-md">« {{ resolved.reason }} »</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Disponibilité filtrée : dénominateur ajusté -->
|
||||
<template v-if="resolved">
|
||||
<q-separator class="q-my-xs" />
|
||||
<div class="row items-center">
|
||||
<q-input dense outlined type="date" v-model="afterDate" label="Dès le" style="width:170px" />
|
||||
<q-space />
|
||||
<div v-if="summary" class="text-right">
|
||||
<div class="text-h6 text-teal-8" style="line-height:1.1">{{ summary.free_h }} h <span class="text-caption text-grey-6">libres</span></div>
|
||||
<div class="text-caption text-grey-7">{{ summary.techs }} tech{{ summary.techs > 1 ? 's' : '' }} qualifié{{ summary.techs > 1 ? 's' : '' }}{{ primarySkill ? ' « ' + primarySkill + ' »' : '' }} · {{ summary.shift_h }} h de quart</div>
|
||||
</div>
|
||||
</div>
|
||||
<OccupancyBands :skill="primarySkill" :after-date="afterDate" :days="7" :active="true" @day="onOccDay" @loaded="v => occ = v" />
|
||||
</template>
|
||||
<div v-else class="text-caption text-grey-6 q-pt-xs">Choisis un motif ou dicte la raison pour voir la disponibilité ajustée à la compétence.</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions v-if="resolved" align="right" class="q-px-md q-pb-md">
|
||||
<q-btn flat no-caps color="grey-7" label="Fermer" @click="emit('update:modelValue', false)" />
|
||||
<q-btn unelevated no-caps color="primary" icon="event" label="Trouver un créneau" @click="book">
|
||||
<q-tooltip>Ouvre la recherche de créneau pré-remplie ({{ primarySkill || 'sans compétence' }} · {{ durH }} h · dès {{ dayShort(afterDate) }}).</q-tooltip>
|
||||
</q-btn>
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.resolved-row { border: 1px dashed #cbd5e1; border-radius: 8px; padding: 6px 10px; }
|
||||
</style>
|
||||
212
apps/ops/src/components/shared/CommandPalette.vue
Normal file
212
apps/ops/src/components/shared/CommandPalette.vue
Normal 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>
|
||||
|
|
@ -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('')
|
||||
|
|
|
|||
71
apps/ops/src/components/shared/DisclosureSection.vue
Normal file
71
apps/ops/src/components/shared/DisclosureSection.vue
Normal 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>
|
||||
117
apps/ops/src/components/shared/OccupancyBands.vue
Normal file
117
apps/ops/src/components/shared/OccupancyBands.vue
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
<script setup>
|
||||
/**
|
||||
* OccupancyBands — bandes verticales « qui a de la place », FILTRÉES par compétence.
|
||||
*
|
||||
* Grille tech × jours : chaque case = mini-timeline du quart (blocs occupé bleu / réservé orange,
|
||||
* week-end/absence hachuré) + barre d'occupation par tech (vert<50%<ambre<80%<rouge), triée le moins
|
||||
* occupé d'abord. Le DÉNOMINATEUR (heures dispo) ne compte QUE les techs qui ont la compétence `skill`
|
||||
* → « 70 h installation » au lieu de « 150 h tous techs ».
|
||||
*
|
||||
* Extrait de SuggestSlotsDialog pour être RÉUTILISABLE (SuggestSlotsDialog + AvailabilityByReason).
|
||||
* Se recharge seul quand skill/afterDate/days changent ; émet 'loaded' (occ) pour les résumés du parent
|
||||
* et 'day' (cell) au clic sur un jour.
|
||||
*/
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { techOccupancy } from 'src/api/dispatch'
|
||||
|
||||
const props = defineProps({
|
||||
skill: { type: String, default: '' },
|
||||
afterDate: { type: String, default: () => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) },
|
||||
days: { type: Number, default: 7 },
|
||||
active: { type: Boolean, default: true }, // ne charge que si actif (ex. panneau replié → false)
|
||||
})
|
||||
const emit = defineEmits(['loaded', 'day'])
|
||||
|
||||
const occ = ref(null)
|
||||
const occLoading = ref(false)
|
||||
let occTimer = null
|
||||
|
||||
async function load () {
|
||||
if (!props.active) return
|
||||
occLoading.value = true
|
||||
try {
|
||||
occ.value = await techOccupancy({ after_date: props.afterDate, days: props.days, skill: props.skill })
|
||||
emit('loaded', occ.value)
|
||||
} catch (e) {
|
||||
occ.value = null; emit('loaded', null)
|
||||
Notify.create({ type: 'negative', message: 'Occupation indisponible : ' + (e.message || e), timeout: 3000 })
|
||||
} finally { occLoading.value = false }
|
||||
}
|
||||
function schedule () { clearTimeout(occTimer); occTimer = setTimeout(load, 250) }
|
||||
|
||||
watch(() => [props.skill, props.afterDate, props.days], schedule)
|
||||
watch(() => props.active, (v) => { if (v && !occ.value) load() })
|
||||
onMounted(() => { if (props.active) load() })
|
||||
|
||||
// Rendu (identique à l'ancienne grille de SuggestSlotsDialog).
|
||||
const DOW = ['dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam']
|
||||
const pct = (o) => (o == null ? '—' : Math.round(o * 100) + '%')
|
||||
const OFF_LABEL = { weekend: 'week-end', no_shift: 'aucun quart', absence: 'absent', vacation: 'congé' }
|
||||
function offLabel (c) { return OFF_LABEL[c.reason] || 'indisponible' }
|
||||
function occColor (o) { if (o == null) return '#cbd5e1'; if (o < 0.5) return '#22c55e'; if (o < 0.8) return '#f59e0b'; return '#ef4444' }
|
||||
function occShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) }
|
||||
function onDay (cell) { if (!cell || cell.off) return; emit('day', cell) }
|
||||
|
||||
defineExpose({ reload: load })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="occLoading" class="text-center q-pa-sm"><q-spinner size="22px" color="primary" /></div>
|
||||
<div v-else-if="!occ || !occ.techs.length" class="text-caption text-grey-6 q-pa-sm">
|
||||
Aucune ressource{{ skill ? ' pour « ' + skill + ' »' : '' }} sur l'horizon.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="occ-grid" :style="{ '--cols': occ.dates.length }">
|
||||
<div class="occ-name occ-corner"></div>
|
||||
<div v-for="d in occ.dates" :key="'h' + d" class="occ-dayh" :class="{ we: [0,6].includes(new Date(d + 'T12:00:00').getDay()) }">{{ occShort(d) }}</div>
|
||||
<template v-for="t in occ.techs" :key="t.tech_id">
|
||||
<div class="occ-name">
|
||||
<div class="ellipsis text-weight-medium">{{ t.tech_name }}</div>
|
||||
<div class="occ-bar"><div class="occ-bar-fill" :style="{ width: ((t.occupancy || 0) * 100) + '%', background: occColor(t.occupancy) }"></div></div>
|
||||
<div class="occ-sub">{{ pct(t.occupancy) }} · {{ t.free_h }} h libre</div>
|
||||
</div>
|
||||
<div v-for="(c, i) in t.days" :key="t.tech_id + i" class="occ-cell" @click="onDay(c)">
|
||||
<div v-if="c.off" class="occ-strip off"><q-tooltip>{{ occShort(c.date) }} · {{ offLabel(c) }}</q-tooltip></div>
|
||||
<div v-else class="occ-strip clickable">
|
||||
<div v-for="(b, bi) in c.blocks" :key="bi" class="occ-block" :class="{ reserved: b.reserved }"
|
||||
:style="{ top: (b.top * 100) + '%', height: Math.max(3, b.height * 100) + '%' }"></div>
|
||||
<q-tooltip>{{ occShort(c.date) }} · {{ c.shift_start }}–{{ c.shift_end }}<br>{{ pct(c.occupancy) }} occupé · {{ c.free_h }} h libre · {{ c.jobs }} job{{ c.jobs > 1 ? 's' : '' }}</q-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-md q-mt-xs occ-legend">
|
||||
<span><i class="occ-dot job"></i> occupé</span>
|
||||
<span><i class="occ-dot reserved"></i> réservé</span>
|
||||
<span><i class="occ-dot free"></i> libre</span>
|
||||
<span><i class="occ-dot offd"></i> pas de quart</span>
|
||||
<q-space /><span class="text-grey-6">clic sur un jour → cale la recherche</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.occ-grid { display: grid; grid-template-columns: 118px repeat(var(--cols), 1fr); gap: 3px; align-items: stretch; max-height: 42vh; overflow: auto; padding: 2px; }
|
||||
.occ-corner { background: transparent; }
|
||||
.occ-dayh { font-size: 10px; text-align: center; color: #64748b; font-weight: 600; padding-bottom: 2px; text-transform: capitalize; }
|
||||
.occ-dayh.we { color: #b45309; }
|
||||
.occ-name { min-width: 0; padding: 2px 4px 2px 0; display: flex; flex-direction: column; justify-content: center; font-size: 11.5px; }
|
||||
.occ-name .ellipsis { max-width: 112px; }
|
||||
.occ-bar { height: 4px; background: #e5e7eb; border-radius: 3px; overflow: hidden; margin: 2px 0; }
|
||||
.occ-bar-fill { height: 100%; border-radius: 3px; transition: width .2s; }
|
||||
.occ-sub { font-size: 9.5px; color: #64748b; }
|
||||
.occ-cell { display: flex; }
|
||||
.occ-strip { position: relative; flex: 1; min-height: 46px; border-radius: 4px; background: #dcfce7; border: 1px solid #bbf7d0; overflow: hidden; cursor: pointer; }
|
||||
.occ-strip.off { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 4px, #e2e8f0 4px, #e2e8f0 8px); border-color: #e2e8f0; cursor: default; }
|
||||
.occ-block { position: absolute; left: 1px; right: 1px; background: #2563eb; border-radius: 2px; }
|
||||
.occ-block.reserved { background: #f59e0b; }
|
||||
.occ-legend { font-size: 10.5px; color: #475569; }
|
||||
.occ-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
|
||||
.occ-dot.job { background: #2563eb; }
|
||||
.occ-dot.reserved { background: #f59e0b; }
|
||||
.occ-dot.free { background: #dcfce7; border: 1px solid #bbf7d0; }
|
||||
.occ-dot.offd { background: #e2e8f0; }
|
||||
</style>
|
||||
79
apps/ops/src/components/shared/OverflowMenu.vue
Normal file
79
apps/ops/src/components/shared/OverflowMenu.vue
Normal 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 d’actions' },
|
||||
ariaLabel: { type: String, default: 'Plus d’actions' },
|
||||
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>
|
||||
|
|
@ -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é ──
|
||||
|
||||
|
|
|
|||
52
apps/ops/src/composables/useCommandPalette.js
Normal file
52
apps/ops/src/composables/useCommandPalette.js
Normal 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 l’horaire', 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -9,8 +9,9 @@
|
|||
*/
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { suggestSlots, techOccupancy } from 'src/api/dispatch'
|
||||
import { suggestSlots } from 'src/api/dispatch'
|
||||
import { useAddressSearch } from 'src/composables/useAddressSearch'
|
||||
import OccupancyBands from 'src/components/shared/OccupancyBands.vue' // bandes d'occupation partagées (dénominateur filtré par compétence)
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
|
|
@ -34,21 +35,10 @@ const slots = ref([])
|
|||
const searched = ref(false)
|
||||
|
||||
// ── Occupation des ressources (bandes verticales) ────────────────────────────
|
||||
// Panneau dépliable : occupation par tech sur 7 jours, FILTRÉE par la compétence choisie → on voit qui a de la place
|
||||
// AVANT même de saisir l'adresse (l'occupation ne dépend que du skill + de la date, pas du lieu).
|
||||
// Panneau dépliable : la grille elle-même vit dans <OccupancyBands> (partagée). Ici on ne garde que
|
||||
// l'état du panneau + le dernier `occ` (pour la légende/caption). Le composant se recharge seul.
|
||||
const occOpen = ref(false)
|
||||
const occ = ref(null)
|
||||
const occLoading = ref(false)
|
||||
let occTimer = null
|
||||
async function loadOcc () {
|
||||
occLoading.value = true
|
||||
try { occ.value = await techOccupancy({ after_date: afterDate.value, days: 7, skill: skill.value }) }
|
||||
catch (e) { occ.value = null; Notify.create({ type: 'negative', message: 'Occupation indisponible : ' + (e.message || e), timeout: 3000 }) }
|
||||
finally { occLoading.value = false }
|
||||
}
|
||||
function scheduleOcc () { if (!occOpen.value) return; clearTimeout(occTimer); occTimer = setTimeout(loadOcc, 250) }
|
||||
watch(occOpen, (v) => { if (v && !occ.value) loadOcc() })
|
||||
watch([skill, afterDate], scheduleOcc) // re-filtre l'occupation quand la compétence ou la date change (si le panneau est ouvert)
|
||||
|
||||
// Reset à l'ouverture (formulaire propre à chaque fois) — puis pré-remplissage éventuel (adresse/coords/compétence du contexte appelant).
|
||||
watch(() => props.modelValue, (o) => {
|
||||
|
|
@ -61,13 +51,8 @@ watch(() => props.modelValue, (o) => {
|
|||
})
|
||||
|
||||
function pickSkill (s) { skill.value = s.skill; durationH.value = s.dur || 1 } // 1 clic → skill + durée par défaut
|
||||
// Clic sur une case-jour de l'occupation → cale la recherche sur ce jour (et lance si l'adresse est déjà choisie).
|
||||
// Clic sur une case-jour de l'occupation (émis par <OccupancyBands>) → cale la recherche sur ce jour (et lance si l'adresse est déjà choisie).
|
||||
function onOccDay (cell) { if (!cell || cell.off) return; afterDate.value = cell.date; if (target.latitude != null) search() }
|
||||
const pct = (o) => (o == null ? '—' : Math.round(o * 100) + '%')
|
||||
const OFF_LABEL = { weekend: 'week-end', no_shift: 'aucun quart', absence: 'absent', vacation: 'congé' }
|
||||
function offLabel (c) { return OFF_LABEL[c.reason] || 'indisponible' }
|
||||
// Couleur d'occupation (vert = libre → rouge = plein) pour la barre globale du tech.
|
||||
function occColor (o) { if (o == null) return '#cbd5e1'; if (o < 0.5) return '#22c55e'; if (o < 0.8) return '#f59e0b'; return '#ef4444' }
|
||||
function occShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.getDay()] + ' ' + iso.slice(8) }
|
||||
function onAddrInput (v) { target.latitude = null; target.longitude = null; searchAddr(v) } // retape l'adresse → invalide les coords
|
||||
function onPickAddr (a) { selectAddr(a, target); addrResults.value = [] }
|
||||
|
|
@ -115,38 +100,7 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
|||
icon="bar_chart" :label="skill ? 'Occupation — « ' + skill + ' »' : 'Occupation des ressources'"
|
||||
:caption="occ ? (occ.techs.length + ' ressource' + (occ.techs.length > 1 ? 's' : '') + ' · dès ' + occShort(afterDate)) : 'Voir la charge par tech'">
|
||||
<div class="q-pt-xs">
|
||||
<div v-if="occLoading" class="text-center q-pa-sm"><q-spinner size="22px" color="primary" /></div>
|
||||
<div v-else-if="!occ || !occ.techs.length" class="text-caption text-grey-6 q-pa-sm">Aucune ressource{{ skill ? ' pour « ' + skill + ' »' : '' }} sur l'horizon.</div>
|
||||
<template v-else>
|
||||
<div class="occ-grid" :style="{ '--cols': occ.dates.length }">
|
||||
<!-- en-tête jours -->
|
||||
<div class="occ-name occ-corner"></div>
|
||||
<div v-for="d in occ.dates" :key="'h' + d" class="occ-dayh" :class="{ we: [0,6].includes(new Date(d + 'T12:00:00').getDay()) }">{{ occShort(d) }}</div>
|
||||
<!-- une ligne par tech -->
|
||||
<template v-for="t in occ.techs" :key="t.tech_id">
|
||||
<div class="occ-name">
|
||||
<div class="ellipsis text-weight-medium">{{ t.tech_name }}</div>
|
||||
<div class="occ-bar"><div class="occ-bar-fill" :style="{ width: ((t.occupancy || 0) * 100) + '%', background: occColor(t.occupancy) }"></div></div>
|
||||
<div class="occ-sub">{{ pct(t.occupancy) }} · {{ t.free_h }} h libre</div>
|
||||
</div>
|
||||
<div v-for="(c, i) in t.days" :key="t.tech_id + i" class="occ-cell" @click="onOccDay(c)">
|
||||
<div v-if="c.off" class="occ-strip off"><q-tooltip>{{ occShort(c.date) }} · {{ offLabel(c) }}</q-tooltip></div>
|
||||
<div v-else class="occ-strip" :class="{ clickable: true }">
|
||||
<div v-for="(b, bi) in c.blocks" :key="bi" class="occ-block" :class="{ reserved: b.reserved }"
|
||||
:style="{ top: (b.top * 100) + '%', height: Math.max(3, b.height * 100) + '%' }"></div>
|
||||
<q-tooltip>{{ occShort(c.date) }} · {{ c.shift_start }}–{{ c.shift_end }}<br>{{ pct(c.occupancy) }} occupé · {{ c.free_h }} h libre · {{ c.jobs }} job{{ c.jobs > 1 ? 's' : '' }}</q-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-md q-mt-xs occ-legend">
|
||||
<span><i class="occ-dot job"></i> occupé</span>
|
||||
<span><i class="occ-dot reserved"></i> réservé</span>
|
||||
<span><i class="occ-dot free"></i> libre</span>
|
||||
<span><i class="occ-dot offd"></i> pas de quart</span>
|
||||
<q-space /><span class="text-grey-6">clic sur un jour → cale la recherche</span>
|
||||
</div>
|
||||
</template>
|
||||
<OccupancyBands :skill="skill" :after-date="afterDate" :days="7" :active="occOpen" @day="onOccDay" @loaded="v => occ = v" />
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
|
|
@ -204,27 +158,5 @@ function dayLabel (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
|||
<style scoped>
|
||||
.ss-chip { display: inline-flex; align-items: center; padding: 3px 11px; border: 1.5px solid #cbd5e1; border-radius: 14px; font-size: 12.5px; font-weight: 600; cursor: pointer; user-select: none; background: #fff; text-transform: capitalize; }
|
||||
.ss-addr-list { border-radius: 6px; max-height: 168px; overflow-y: auto; }
|
||||
|
||||
/* ── Occupation : grille tech × jours, chaque case = bande verticale (mini-timeline du quart) ── */
|
||||
.ss-occ-exp { border: 1px solid #e2e8f0; border-radius: 8px; }
|
||||
.occ-grid { display: grid; grid-template-columns: 118px repeat(var(--cols), 1fr); gap: 3px; align-items: stretch; max-height: 42vh; overflow: auto; padding: 2px; }
|
||||
.occ-corner { background: transparent; }
|
||||
.occ-dayh { font-size: 10px; text-align: center; color: #64748b; font-weight: 600; padding-bottom: 2px; text-transform: capitalize; }
|
||||
.occ-dayh.we { color: #b45309; }
|
||||
.occ-name { min-width: 0; padding: 2px 4px 2px 0; display: flex; flex-direction: column; justify-content: center; font-size: 11.5px; }
|
||||
.occ-name .ellipsis { max-width: 112px; }
|
||||
.occ-bar { height: 4px; background: #e5e7eb; border-radius: 3px; overflow: hidden; margin: 2px 0; }
|
||||
.occ-bar-fill { height: 100%; border-radius: 3px; transition: width .2s; }
|
||||
.occ-sub { font-size: 9.5px; color: #64748b; }
|
||||
.occ-cell { display: flex; }
|
||||
.occ-strip { position: relative; flex: 1; min-height: 46px; border-radius: 4px; background: #dcfce7; border: 1px solid #bbf7d0; overflow: hidden; cursor: pointer; }
|
||||
.occ-strip.off { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 4px, #e2e8f0 4px, #e2e8f0 8px); border-color: #e2e8f0; cursor: default; }
|
||||
.occ-block { position: absolute; left: 1px; right: 1px; background: #2563eb; border-radius: 2px; }
|
||||
.occ-block.reserved { background: #f59e0b; }
|
||||
.occ-legend { font-size: 10.5px; color: #475569; }
|
||||
.occ-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
|
||||
.occ-dot.job { background: #2563eb; }
|
||||
.occ-dot.reserved { background: #f59e0b; }
|
||||
.occ-dot.free { background: #dcfce7; border: 1px solid #bbf7d0; }
|
||||
.occ-dot.offd { background: #e2e8f0; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@
|
|||
<q-btn dense flat size="sm" no-caps color="teal-8" icon="event_available" label="Trouver un créneau" @click="openFindSlot">
|
||||
<q-tooltip>Prendre RDV (réparation / installation) — pré-rempli avec l'adresse du client</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn dense flat size="sm" no-caps color="indigo-7" icon="fact_check" label="Dispo par motif" @click="openAvailReason">
|
||||
<q-tooltip>Choisir/dicter le motif → voir la disponibilité ajustée à la compétence requise</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn dense flat size="sm" no-caps color="warning" icon="card_giftcard" label="Récompense" @click="rewardOpen = true">
|
||||
<q-tooltip>Carte-cadeau : voir / copier le lien, éditer ou envoyer par courriel</q-tooltip>
|
||||
</q-btn>
|
||||
|
|
@ -593,6 +596,9 @@
|
|||
<!-- « Trouver un créneau » direct (appel / réparation) → crée un Dispatch Job lié au client -->
|
||||
<SuggestSlotsDialog v-model="findSlotOpen" :skills="slotSkills"
|
||||
:initial-address="findSlotAddr" :initial-skill="findSlotSkill" @select="onFindSlotSelected" />
|
||||
<!-- « Dispo par motif » : raison → compétence → disponibilité (dénominateur filtré) → enchaîne sur la recherche de créneau -->
|
||||
<AvailabilityByReason v-model="availReasonOpen" :customer-name="customer?.customer_name || ''"
|
||||
:address="findSlotAddr" @book="onAvailBook" />
|
||||
<UnifiedCreateModal v-model="createJobOpen" mode="work-order"
|
||||
:context="createJobCtx" :locations="locations" @created="onJobCreated" />
|
||||
|
||||
|
|
@ -705,6 +711,8 @@ import InlineField from 'src/components/shared/InlineField.vue'
|
|||
import { useSoftphone } from 'src/composables/useSoftphone'
|
||||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
||||
import SuggestSlotsDialog from 'src/modules/dispatch/components/SuggestSlotsDialog.vue' // « Trouver un créneau » direct depuis la fiche (appel/réparation)
|
||||
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré)
|
||||
import { DEPARTMENTS } from 'src/config/departments'
|
||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||
import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue'
|
||||
import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
|
||||
|
|
@ -1198,14 +1206,12 @@ async function loadJobs () {
|
|||
const findSlotOpen = ref(false)
|
||||
const findSlotAddr = ref('')
|
||||
const findSlotSkill = ref('')
|
||||
const availReasonOpen = ref(false)
|
||||
const createJobOpen = ref(false)
|
||||
const createJobCtx = ref({})
|
||||
const slotSkills = [
|
||||
{ skill: 'réparation', dur: 1, color: '#ef4444' },
|
||||
{ skill: 'installation', dur: 2, color: '#f59e0b' },
|
||||
{ skill: 'tv', dur: 1, color: '#8b5cf6' },
|
||||
{ skill: 'téléphonie', dur: 0.5, color: '#0ea5e9' },
|
||||
]
|
||||
// Chips de compétence dérivés de la SOURCE UNIQUE (config/departments.js) — plus de liste codée en dur.
|
||||
const SKILL_COLORS = { installation: '#f59e0b', 'réparation': '#ef4444', tv: '#8b5cf6', telephone: '#0ea5e9', 'épissure': '#10b981', monteur: '#6366f1' }
|
||||
const slotSkills = DEPARTMENTS.filter(d => (d.skills || []).length).map(d => ({ skill: d.skills[0], dur: d.dur || 1, color: SKILL_COLORS[d.skills[0]] || '#64748b' }))
|
||||
function primaryLocation () {
|
||||
const list = (sortedLocations.value && sortedLocations.value.length) ? sortedLocations.value : locations.value
|
||||
return (list || []).find(l => locHasSubs(l.name)) || (list || [])[0] || null
|
||||
|
|
@ -1216,6 +1222,18 @@ function openFindSlot () {
|
|||
findSlotSkill.value = 'réparation' // appel client = réparation le plus souvent ; l'agent peut changer la compétence
|
||||
findSlotOpen.value = true
|
||||
}
|
||||
// « Dispo par motif » : pré-remplit l'adresse du lieu de service puis ouvre le widget raison→compétence→dispo.
|
||||
function openAvailReason () {
|
||||
const loc = primaryLocation()
|
||||
findSlotAddr.value = (loc && (loc.address_line || loc.address_full)) || ''
|
||||
availReasonOpen.value = true
|
||||
}
|
||||
// Le widget a résolu une compétence → enchaîne sur la recherche de créneau, pré-remplie (compétence + adresse).
|
||||
function onAvailBook (b) {
|
||||
findSlotSkill.value = b.primary || ''
|
||||
findSlotAddr.value = b.address || findSlotAddr.value
|
||||
findSlotOpen.value = true
|
||||
}
|
||||
function onFindSlotSelected (slot) {
|
||||
const loc = primaryLocation()
|
||||
createJobCtx.value = {
|
||||
|
|
|
|||
|
|
@ -25,8 +25,13 @@
|
|||
<div class="row items-center q-mb-sm">
|
||||
<div class="text-subtitle1 text-weight-bold">Charge <span class="text-caption text-grey-6 q-ml-xs">2 semaines</span></div>
|
||||
<q-space />
|
||||
<!-- Filtre compétence : le dénominateur ne compte que les techs qualifiés (« libre » = pour CE type de job). -->
|
||||
<q-chip v-for="s in CAP_SKILLS" :key="s" clickable dense size="sm"
|
||||
:color="capSkill === s ? 'teal' : 'grey-3'" :text-color="capSkill === s ? 'white' : 'grey-8'"
|
||||
@click="capSkill = (capSkill === s ? '' : s); loadWeek()">{{ s }}</q-chip>
|
||||
<q-btn flat dense no-caps size="sm" color="primary" icon-right="chevron_right" label="Planification" @click="$router.push('/planification')" />
|
||||
</div>
|
||||
<div v-if="capSkill" class="text-caption text-teal-7 q-mb-xs">Disponibilité pour « {{ capSkill }} » — seuls les techs qualifiés comptent.</div>
|
||||
<template v-if="weekLoad.length">
|
||||
<div class="dash-wk-lbl">Cette semaine</div>
|
||||
<OccupancyStrip :days="weekLoad.slice(0, 7)" :selected="todayIso" @select="iso => $router.push({ path: '/planification', query: { day: iso } })" />
|
||||
|
|
@ -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"> · {{ j.customer }}</span>
|
||||
<template v-if="j.customer"> · <router-link :to="'/clients/' + encodeURIComponent(j.customer)" class="erp-link" @click.stop>{{ j.customer }}</router-link></template>
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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'é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 l’horaire', 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 l’horaire (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; }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers')
|
||||
// Prédicat « ce tech a-t-il la compétence ? » PARTAGÉ (même sémantique que capacityByDay/solveur). Le champ ERP `skills`
|
||||
// (ou `_user_tags`) est une CSV → techHasSkill l'accepte telle quelle. SOURCE UNIQUE : lib/skill-resolver.js.
|
||||
const { techHasSkill } = require('./skill-resolver')
|
||||
// Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement).
|
||||
const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String))
|
||||
|
||||
|
|
@ -163,7 +166,7 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
|
|||
])
|
||||
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
|
||||
// FILTRE COMPÉTENCE : ne garder que les techs qui ONT le skill demandé (champ skills ou tags Frappe).
|
||||
const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
|
||||
const hasSkill = (t) => techHasSkill(t.skills || t._user_tags, wantSkill)
|
||||
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
|
||||
// Jours de congé approuvé par tech (clé = technician, qui correspond au technician_id ou au docname). Aligné sur roster.buildUnavailability.
|
||||
const vacBy = {}
|
||||
|
|
@ -322,7 +325,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
|||
erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
|
||||
])
|
||||
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
|
||||
const hasSkill = (t) => { if (!wantSkill) return true; return String(t.skills || t._user_tags || '').toLowerCase().split(/[,;]/).some(s => { s = s.trim(); return s && (s === wantSkill || (s.length >= 3 && (s.includes(wantSkill) || wantSkill.includes(s)))) }) }
|
||||
const hasSkill = (t) => techHasSkill(t.skills || t._user_tags, wantSkill)
|
||||
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
|
||||
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
|
||||
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
|
||||
|
|
|
|||
|
|
@ -675,18 +675,9 @@ function skillForJob (job) {
|
|||
}
|
||||
// Repli : déduit une COMPÉTENCE (parmi les skills réels des techs) depuis le département/type legacy.
|
||||
// Sert à colorer les tickets par la couleur de leur compétence (éditable via le gestionnaire de tags).
|
||||
function deptToSkill (txt) {
|
||||
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
if (!d) return ''
|
||||
if (/teleph/.test(d)) return 'telephone'
|
||||
if (/tele|televis/.test(d)) return 'tv'
|
||||
if (/fusion|episs/.test(d)) return 'épissure'
|
||||
if (/monteur|aerien/.test(d)) return 'monteur'
|
||||
if (/netadmin|net admin/.test(d)) return 'netadmin'
|
||||
if (/repar|desinstall/.test(d)) return 'réparation'
|
||||
if (/install|fibre/.test(d)) return 'installation'
|
||||
return ''
|
||||
}
|
||||
// SOURCE UNIQUE : la logique vit dans lib/skill-resolver.js (partagée avec le résolveur raison→compétence
|
||||
// et le prédicat de disponibilité) ; ré-exportée ici pour ne rien casser des appelants existants.
|
||||
const { deptToSkill, techHasSkills } = require('./skill-resolver')
|
||||
// Enrichit des jobs avec une adresse LISIBLE (le champ service_location est un code « LOC-… »).
|
||||
// Batch : 1 seule requête sur Service Location pour tous les codes distincts.
|
||||
async function attachLocations (jobs) {
|
||||
|
|
@ -1151,7 +1142,7 @@ async function occupancyByTechDay (start, days) {
|
|||
// Inclut Completed/Cancelled : la grille montre AUSSI le travail fait/annulé de la journée (trace du tech),
|
||||
// pas seulement le planifiable. (Filtrés par assigned_tech ci-dessous : les annulés « non assigné » n'apparaissent pas.)
|
||||
filters: [['scheduled_date', 'in', dates], ['status', 'in', ['open', 'assigned', 'in_progress', 'In Progress', 'Completed', 'Cancelled']]],
|
||||
fields: ['name', 'subject', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end', 'status'], limit: 5000,
|
||||
fields: ['name', 'subject', 'customer', 'customer_name', 'service_type', 'job_type', 'legacy_dept', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'route_order', 'latitude', 'longitude', 'booking_status', 'legacy_detail', 'legacy_ticket_id', 'address', 'service_location', 'actual_start', 'actual_end', 'status'], limit: 5000,
|
||||
})
|
||||
const PRIO = { urgent: 0, high: 1, medium: 2, low: 3 } // ordre d'affichage
|
||||
const occChars = readJobChar().items // table additive (1 lecture) → durée EFFECTIVE estimée par job
|
||||
|
|
@ -1172,7 +1163,7 @@ async function occupancyByTechDay (start, days) {
|
|||
const s = j.start_time ? timeToH(j.start_time) : null
|
||||
if (s != null && !cancelled) o.blocks.push({ s, e: s + dur, skill, job: j.name, done }) // 1 bloc = 1 job, coloré par sa compétence
|
||||
const actualMin = (j.actual_start && j.actual_end) ? Math.max(0, Math.round((Date.parse(j.actual_end.replace(' ', 'T')) - Date.parse(j.actual_start.replace(' ', 'T'))) / 60000)) : null
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
|
||||
o.jobs.push({ name: j.name, subject: j.subject || j.service_type || j.name, customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: estMin, priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', status: j.status || '', done, cancelled, legacy_id: j.legacy_ticket_id || '', legacy_dept: j.legacy_dept || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: j.actual_start || '', actual_end: j.actual_end || '', actual_min: actualMin })
|
||||
}
|
||||
// ── ÉQUIPE : reporte la charge des assistants ÉPINGLÉS sur LEUR propre lane (mirror) ──
|
||||
// 1 requête enfant UNIQUE (pas de N+1). Le lead reste compté via assigned_tech ci-dessus ;
|
||||
|
|
@ -1197,7 +1188,7 @@ async function occupancyByTechDay (start, days) {
|
|||
o.h += dur
|
||||
const s = j.start_time ? timeToH(j.start_time) : null
|
||||
if (s != null) o.blocks.push({ s, e: s + dur, skill, job: j.name, assist: true })
|
||||
o.jobs.push({ name: j.name, subject: '👥 ' + (j.subject || j.service_type || j.name), customer: j.customer_name || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: Math.round(dur * 60), priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: '', actual_end: '', actual_min: null, assist: true, lead_tech: j.assigned_tech || '' })
|
||||
o.jobs.push({ name: j.name, subject: '👥 ' + (j.subject || j.service_type || j.name), customer: j.customer_name || '', customer_id: j.customer || '', address: j.address || '', service_location: j.service_location || '', start: j.start_time ? String(j.start_time).slice(0, 5) : '', start_h: s, dur, est_min: Math.round(dur * 60), priority: j.priority || 'low', skill, route_order: Number(j.route_order) || 0, lat: j.latitude != null ? Number(j.latitude) : null, lon: j.longitude != null ? Number(j.longitude) : null, booking_status: j.booking_status || '', legacy_id: j.legacy_ticket_id || '', detail: (j.legacy_detail || '').slice(0, 400), actual_start: '', actual_end: '', actual_min: null, assist: true, lead_tech: j.assigned_tech || '' })
|
||||
}
|
||||
}
|
||||
// ordre = route_order manuel s'il existe, sinon priorité puis heure
|
||||
|
|
@ -1239,7 +1230,7 @@ const DAY_SEGMENTS = [
|
|||
]
|
||||
const segOverlap = (s, e, a, b) => Math.max(0, Math.min(e, b) - Math.max(s, a))
|
||||
const r1 = (x) => Math.round(x * 10) / 10
|
||||
async function capacityByDay (start, days) {
|
||||
async function capacityByDay (start, days, skills = null) {
|
||||
const dates = rangeDates(start, days)
|
||||
const templates = await fetchTemplates()
|
||||
const tpl = {}; for (const t of templates) tpl[t.name] = { s: timeToH(t.start_time), e: timeToH(t.end_time) }
|
||||
|
|
@ -1247,7 +1238,11 @@ async function capacityByDay (start, days) {
|
|||
// Capacité RÉELLE : un quart matérialisé subsiste même quand le tech est en congé/pause/archivé → on retranche ces
|
||||
// (tech, jour) sinon le dénominateur gonfle (ex. « 24/150 » alors que plusieurs techs sont absents). Aligné sur le
|
||||
// moteur de créneaux + le solveur (buildUnavailability = En pause + absence_from/until + Tech Availability approuvé).
|
||||
const techsActive = await fetchTechnicians() // exclut les techs archivés
|
||||
let techsActive = await fetchTechnicians() // exclut les techs archivés
|
||||
// FILTRE COMPÉTENCE (optionnel) : le dénominateur ne compte QUE les techs qui ont la/les compétence(s) demandée(s).
|
||||
// Ex. « installation » → seuls les installateurs comptent (fini « 150 h dispo » alors que la moitié ne peut pas installer).
|
||||
const wantSkills = (Array.isArray(skills) ? skills : (skills ? [skills] : [])).map(s => String(s || '').trim()).filter(Boolean)
|
||||
if (wantSkills.length) techsActive = techsActive.filter(t => techHasSkills(t.skills, wantSkills))
|
||||
const validTech = new Set(techsActive.map(t => t.id))
|
||||
const unavail = await buildUnavailability(techsActive, dates)
|
||||
const isUnavailable = (tech, date) => !validTech.has(tech) || (unavail[tech] && unavail[tech].has(date))
|
||||
|
|
@ -1267,6 +1262,10 @@ async function capacityByDay (start, days) {
|
|||
}
|
||||
for (const j of jobs) {
|
||||
const o = out[j.scheduled_date]; if (!o) continue
|
||||
// Sous filtre compétence : ne compter que ce qui occupe VRAIMENT un tech qualifié (assigné à un tech du dénominateur).
|
||||
// On ignore les jobs assignés à un tech non qualifié + les jobs non placés (compétence inconnue ici) → used/due
|
||||
// restent cohérents avec la capacité réduite (sinon due_h > cap_h ⇒ « surbooké » faux systématique).
|
||||
if (wantSkills.length && (!j.assigned_tech || !validTech.has(j.assigned_tech))) continue
|
||||
const dur = Number(j.duration_h) || 0
|
||||
o.due_h += dur; o.jobs_due++
|
||||
const sh = j.start_time ? timeToH(j.start_time) : null
|
||||
|
|
@ -1333,7 +1332,15 @@ async function handle (req, res, method, path, url) {
|
|||
}
|
||||
if (path === '/roster/capacity' && method === 'GET') { // dispo restante par jour × segment (AM/PM/Soir)
|
||||
if (!start) return json(res, 400, { error: 'start requis' })
|
||||
return json(res, 200, { capacity: await capacityByDay(start, days), segments: DAY_SEGMENTS })
|
||||
// ?skill=installation (ou ?skill=a,b) → dénominateur filtré : seuls les techs qui ont la/les compétence(s).
|
||||
const skills = (qs.get('skill') || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
return json(res, 200, { capacity: await capacityByDay(start, days, skills), segments: DAY_SEGMENTS, skill: skills })
|
||||
}
|
||||
// Résolveur « raison → compétence(s) » — appelé par <AvailabilityByReason> (ticket / courriel / fiche client).
|
||||
// Body : { text?, department?, jobType?, useAI? } → { skills, primary, confidence, source, reason }.
|
||||
if (path === '/roster/resolve-skills' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
return json(res, 200, await require('./skill-resolver').resolveSkills(b || {}))
|
||||
}
|
||||
// Proxys OSRM pour le SPA (points = [[lon,lat],…]) — remplacent Mapbox Matrix/Directions (payants) par l'OSRM auto-hébergé.
|
||||
if (path === '/roster/osrm-table' && method === 'POST') {
|
||||
|
|
|
|||
140
services/targo-hub/lib/skill-resolver.js
Normal file
140
services/targo-hub/lib/skill-resolver.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// ── Résolveur « raison → compétence(s) requises » — SOURCE UNIQUE ─────────────
|
||||
// Pont réutilisable entre TROIS systèmes qui, jusqu'ici, ne se parlaient pas :
|
||||
// 1. la RAISON d'une demande (chip de département, type de job, ou texte dicté) ;
|
||||
// 2. la/les COMPÉTENCE(S) réelle(s) d'un technicien (installation, tv, réparation…) ;
|
||||
// 3. la DISPONIBILITÉ filtrée par compétence (dénominateur des bandes d'occupation).
|
||||
//
|
||||
// Appelable de PARTOUT (ticket, courriel, fiche client) via le composant front
|
||||
// <AvailabilityByReason> et l'endpoint /roster/resolve-skills. Un seul endroit à
|
||||
// éditer pour faire évoluer la correspondance raison→compétence.
|
||||
//
|
||||
// N'importe RIEN de roster.js/dispatch.js (pour éviter tout cycle) — au contraire,
|
||||
// roster.js ré-exporte `deptToSkill` d'ici pour ne pas dupliquer la logique.
|
||||
const cfg = require('./config')
|
||||
const { log } = require('./helpers')
|
||||
|
||||
// Vocabulaire CANONIQUE des compétences terrain (= sorties possibles de deptToSkill).
|
||||
// Une demande à distance / facturation / vente ne requiert AUCUNE compétence → [].
|
||||
const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin']
|
||||
|
||||
function normSkill (s) { return String(s || '').trim().toLowerCase() }
|
||||
function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') }
|
||||
|
||||
// ── Prédicat PARTAGÉ « ce technicien a-t-il la compétence ? » ──────────────────
|
||||
// Sémantique IDENTIQUE à celle qui vivait (dupliquée) dans dispatch.js techOccupancy
|
||||
// + suggestSlots : match exact OU inclusion (≥3 car.) tolérant aux variantes.
|
||||
// techSkills accepte un TABLEAU (fetchTechnicians) OU une CSV brute (champ ERP / _user_tags).
|
||||
function techHasSkill (techSkills, want) {
|
||||
const w = normSkill(want)
|
||||
if (!w) return true
|
||||
const list = Array.isArray(techSkills) ? techSkills : String(techSkills || '').split(/[,;]/)
|
||||
return list.some(s => {
|
||||
s = normSkill(s)
|
||||
return s && (s === w || (s.length >= 3 && (s.includes(w) || w.includes(s))))
|
||||
})
|
||||
}
|
||||
// Plusieurs compétences requises → le tech doit les avoir TOUTES (aligné sur techCovers du solveur).
|
||||
function techHasSkills (techSkills, wants) {
|
||||
const ws = (Array.isArray(wants) ? wants : [wants]).map(normSkill).filter(Boolean)
|
||||
if (!ws.length) return true
|
||||
return ws.every(w => techHasSkill(techSkills, w))
|
||||
}
|
||||
|
||||
// ── Repli déterministe : texte/département legacy → UNE compétence ─────────────
|
||||
// (déplacé de roster.js — logique pure, réutilisée partout ; roster.js ré-exporte.)
|
||||
function deptToSkill (txt) {
|
||||
const d = stripAccents(txt).toLowerCase()
|
||||
if (!d) return ''
|
||||
if (/teleph/.test(d)) return 'telephone'
|
||||
if (/tele|televis/.test(d)) return 'tv'
|
||||
if (/fusion|episs/.test(d)) return 'épissure'
|
||||
if (/monteur|aerien/.test(d)) return 'monteur'
|
||||
if (/netadmin|net admin/.test(d)) return 'netadmin'
|
||||
if (/repar|desinstall/.test(d)) return 'réparation'
|
||||
if (/install|fibre/.test(d)) return 'installation'
|
||||
return ''
|
||||
}
|
||||
|
||||
// ── Pont DÉPARTEMENT/CATÉGORIE → compétence(s) ────────────────────────────────
|
||||
// Clés = catégories de departments.js (SPA) ET valeurs CAT de categories.js (hub).
|
||||
// [] = aucune compétence terrain (traité à distance / au bureau) → dénominateur = tous les techs.
|
||||
const DEPARTMENT_SKILLS = {
|
||||
Installation: ['installation'],
|
||||
'Installation Fibre': ['installation'],
|
||||
Réparation: ['réparation'],
|
||||
Reparation: ['réparation'],
|
||||
Télévision: ['tv'],
|
||||
Television: ['tv'],
|
||||
Téléphonie: ['telephone'],
|
||||
Telephonie: ['telephone'],
|
||||
Monteur: ['monteur'],
|
||||
Épissure: ['épissure'],
|
||||
Fusionneur: ['épissure'],
|
||||
'Net Admin': ['netadmin'],
|
||||
Support: [], // dépannage à distance → n'importe quel agent
|
||||
Facturation: [],
|
||||
Commercial: [], // vente / bureau
|
||||
Vente: [],
|
||||
Autre: [],
|
||||
}
|
||||
function deptSkills (dep) {
|
||||
if (!dep) return null
|
||||
if (Object.prototype.hasOwnProperty.call(DEPARTMENT_SKILLS, dep)) return DEPARTMENT_SKILLS[dep]
|
||||
// tolérant aux accents/casse (« telephonie » → « Téléphonie »)
|
||||
const key = Object.keys(DEPARTMENT_SKILLS).find(k => stripAccents(k).toLowerCase() === stripAccents(dep).toLowerCase())
|
||||
return key ? DEPARTMENT_SKILLS[key] : null
|
||||
}
|
||||
|
||||
// ── Chemin IA : texte libre dicté → compétence(s) ─────────────────────────────
|
||||
// Réutilise le PATRON de inbox-triage.classifyEmail : JSON strict + reasoningEffort:'none'
|
||||
// (sinon gemini « pense » et tronque le JSON → parse échoue silencieusement). Focalisé sur
|
||||
// LA compétence (pas de match client), contraint au vocabulaire réel des techs.
|
||||
async function aiSkills (text) {
|
||||
if (!cfg.AI_API_KEY) return null
|
||||
const sys = 'Tu assignes la ou les COMPÉTENCE(S) technique(s) requises pour traiter la demande d\'un client d\'un fournisseur Internet/télécom par fibre (TARGO / Gigafibre). ' +
|
||||
'Choisis UNIQUEMENT parmi cette liste : ' + SKILL_VOCAB.join(', ') + '. ' +
|
||||
'Un simple dépannage à distance, une question de facturation ou une vente ne requièrent AUCUNE compétence terrain → réponds "skills":[]. ' +
|
||||
'Réponds UNIQUEMENT en JSON, sans texte autour : {"skills":["…"],"reason":"une courte phrase en français"}.'
|
||||
try {
|
||||
const out = await require('./ai').chat({ task: 'triage', maxTokens: 150, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: String(text || '').slice(0, 900) }] })
|
||||
const j = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0])
|
||||
const skills = (Array.isArray(j.skills) ? j.skills : []).map(normSkill).filter(s => SKILL_VOCAB.includes(s))
|
||||
return { skills, reason: String(j.reason || '').slice(0, 200) }
|
||||
} catch (e) { log('aiSkills error:', e.message); return null }
|
||||
}
|
||||
|
||||
// ── Résolveur unifié ──────────────────────────────────────────────────────────
|
||||
// resolveSkills({ text, department, jobType, useAI }) → { skills, primary, confidence, source, department, reason }
|
||||
// Priorité : chip/département explicite (sûr) > type de job / regex sur le texte (moyen) > IA sur texte libre (moyen).
|
||||
// confidence : 'high' (chip) · 'medium' (règle/IA) · 'low' (rien trouvé → aucune compétence, tous les techs).
|
||||
async function resolveSkills ({ text = '', department = '', jobType = '', useAI = false } = {}) {
|
||||
// 1. Département / chip explicite = intention claire de l'agent.
|
||||
const ds = deptSkills(department)
|
||||
if (ds) return { skills: ds, primary: ds[0] || '', confidence: 'high', source: 'chip', department, reason: '' }
|
||||
|
||||
// 2. Type de job explicite (Installation/Réparation…), puis regex sur le texte.
|
||||
const rule = deptToSkill(jobType) || deptToSkill(text)
|
||||
if (rule) return { skills: [rule], primary: rule, confidence: 'medium', source: 'rule', department, reason: '' }
|
||||
|
||||
// 3. IA sur le texte dicté (seulement si demandé ET clé dispo).
|
||||
if (useAI && String(text || '').trim().length >= 4) {
|
||||
const ai = await aiSkills(text)
|
||||
if (ai && ai.skills.length) return { skills: ai.skills, primary: ai.skills[0], confidence: 'medium', source: 'ai', department, reason: ai.reason }
|
||||
if (ai) return { skills: [], primary: '', confidence: 'low', source: 'ai', department, reason: ai.reason }
|
||||
}
|
||||
|
||||
// 4. Rien de fiable → aucune compétence (le dénominateur reste « tous les techs »).
|
||||
return { skills: [], primary: '', confidence: 'low', source: 'none', department, reason: '' }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SKILL_VOCAB,
|
||||
normSkill,
|
||||
techHasSkill,
|
||||
techHasSkills,
|
||||
deptToSkill,
|
||||
deptSkills,
|
||||
DEPARTMENT_SKILLS,
|
||||
aiSkills,
|
||||
resolveSkills,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user