Compare commits
10 Commits
3489576212
...
14530787bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14530787bb | ||
|
|
cfcd7c04cc | ||
|
|
9c49054b8d | ||
|
|
9145624949 | ||
|
|
04fb52426f | ||
|
|
7df6ad30e3 | ||
|
|
1552df3984 | ||
|
|
2a9f201826 | ||
|
|
cab1a9c94a | ||
|
|
929ef73d93 |
|
|
@ -66,6 +66,8 @@ export const groupJobs = (parent, children) => jpost('/roster/job-group', { pare
|
|||
export const postJobComment = ({ job, lid, text, isPublic, agentName }) => jpost('/roster/job-comment', { job, lid, text, public: !!isPublic, agentName: agentName || '' })
|
||||
// #5 — Réserver le temps d'un tech (bloc « Réservation » priorité moyenne) → dé-priorise au dispatch auto + soustrait des créneaux.
|
||||
export const reserveTech = ({ tech, date, start_time, duration_h, reason }) => jpost('/roster/reserve', { tech, date, start_time, duration_h, reason })
|
||||
// Job « bouche-trou » : job générique standard assigné → retire le tech du dispatch (option : génère un ticket).
|
||||
export const createFillerJob = (body) => jpost('/roster/filler-job', body || {})
|
||||
export async function deleteShiftTemplate (name) {
|
||||
const r = await fetch(HUB + '/roster/template/' + encodeURIComponent(name), { method: 'DELETE' })
|
||||
if (!r.ok) throw new Error('Suppression modèle: ' + r.status)
|
||||
|
|
|
|||
|
|
@ -34,14 +34,6 @@
|
|||
:input-style="{ color: statusColor }"
|
||||
style="min-width:100px;max-width:140px;text-align:right"
|
||||
@update:model-value="saveStatus" />
|
||||
<div class="q-mt-xs q-gutter-x-xs">
|
||||
<q-btn flat dense size="xs" icon="open_in_new" label="ERPNext"
|
||||
:href="erpDeskUrl + '/app/customer/' + customer.name" target="_blank"
|
||||
class="text-grey-6" no-caps />
|
||||
<q-btn flat dense size="xs" icon="manage_accounts" label="Users"
|
||||
:href="erpDeskUrl + '/app/user?customer=' + customer.name" target="_blank"
|
||||
class="text-grey-6" no-caps />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -75,7 +67,6 @@
|
|||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import { ERP_DESK_URL as erpDeskUrl } from 'src/config/erpnext'
|
||||
import { updateDoc } from 'src/api/erp'
|
||||
|
||||
const contactOpen = ref(false)
|
||||
|
|
|
|||
312
apps/ops/src/components/planif/JobPool.vue
Normal file
312
apps/ops/src/components/planif/JobPool.vue
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<script setup>
|
||||
/**
|
||||
* JobPool — liste UNIFIÉE « Jobs à assigner » (source unique, remplace les 3 réimplémentations divergentes).
|
||||
* variant='floating' → panneau flottant (desktop) : cases à cocher, menu d'assignation, niveau, fil, badges.
|
||||
* variant='docked' → colonne « Jour »/kanban : recherche + tri, cartes glissables (drag-only).
|
||||
* variant='mobile' → liste tactile : chips compétence, tri, swipe = sélectionner, tap = feuille.
|
||||
*
|
||||
* Le composant est PRÉSENTATIONNEL : l'état/dérivés viennent du prop `pool` (instance useJobPool, détenue par la page,
|
||||
* → la carte du panneau garde sa source de vérité) et TOUS les handlers/formatters de `inject('jobPoolCtx')` (fournis
|
||||
* une seule fois par la page → selectedJobs/draggingSet gardent la MÊME identité, donc le bulk-assign reste cohérent).
|
||||
*/
|
||||
import { reactive, computed, inject } from 'vue'
|
||||
import { skillSym } from 'src/composables/useSkillIcons' // icône de compétence (source unique)
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'floating' }, // floating | docked | mobile
|
||||
pool: { type: Object, required: true }, // instance useJobPool
|
||||
selectedDay: { type: String, default: '' }, // mobile : jour sélectionné (en-têtes secteur + tri « pertinence »)
|
||||
})
|
||||
|
||||
const ctx = inject('jobPoolCtx') // handlers + état partagé + formatters (fournis par la page)
|
||||
|
||||
const MOBILE_SORTS = [{ value: 'smart', label: 'Pertinence' }, { value: 'sector', label: 'Secteur' }, { value: 'date', label: 'Date prévue' }, { value: 'prio', label: 'Priorité' }, { value: 'dur', label: 'Durée' }]
|
||||
const DOCKED_SORTS = [{ label: 'Priorité', value: 'prio' }, { label: 'Ville', value: 'city' }, { label: 'Distance (dépôt)', value: 'dist' }, { label: 'Compétence', value: 'skill' }, { label: 'Durée', value: 'dur' }]
|
||||
|
||||
// `pool` est un objet PLAIN de refs (instance useJobPool) → les refs imbriquées ne se déballent PAS dans le template.
|
||||
// On les ré-expose ici en bindings locaux (déballés + réactifs) ; les tris/recherche/filtres sont writables (get/set).
|
||||
const P = props.pool
|
||||
const filteredJobs = computed(() => P.filteredJobs.value)
|
||||
const groups = computed(() => P.groups.value)
|
||||
const skillChips = computed(() => P.skillChips.value)
|
||||
const dateChips = computed(() => P.dateChips.value)
|
||||
const allCollapsed = computed(() => P.allCollapsed.value)
|
||||
const collapsed = computed(() => P.collapsed.value)
|
||||
const skillFilter = computed(() => P.skillFilter.value)
|
||||
const dateFilter = computed(() => P.dateFilter.value)
|
||||
const sort = computed({ get: () => P.sort.value, set: v => { P.sort.value = v } })
|
||||
const sortDir = computed({ get: () => P.sortDir.value, set: v => { P.sortDir.value = v } })
|
||||
const search = computed({ get: () => P.search.value, set: v => { P.search.value = v } })
|
||||
|
||||
// Couleur de bordure d'une carte kanban : compétence, sinon département legacy, sinon gris.
|
||||
function kbColor (j) { return j.skill ? ctx.getTagColor(j.skill) : (j.required_skill ? ctx.getTagColor(j.required_skill) : (ctx.legacyDeptColor(j.legacy_dept) || '#90a4ae')) }
|
||||
|
||||
// ── Swipe mobile (pointer events) : tap = ouvrir la feuille (onJobClick) · glissé horizontal franc = sélectionner ──
|
||||
const swipe = reactive({ name: null, x0: 0, y0: 0, dx: 0, active: false })
|
||||
let swipeGuard = false
|
||||
function swipeStart (e, j) { if (e.pointerType === 'mouse' && e.button !== 0) return; swipe.name = j.name; swipe.x0 = e.clientX; swipe.y0 = e.clientY; swipe.dx = 0; swipe.active = false }
|
||||
function swipeMove (e) {
|
||||
if (swipe.name == null) return
|
||||
const dx = e.clientX - swipe.x0; const dy = e.clientY - swipe.y0
|
||||
if (!swipe.active) {
|
||||
if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.4) { swipe.active = true; try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) {} }
|
||||
else if (Math.abs(dy) > 10) { swipe.name = null; return }
|
||||
}
|
||||
if (swipe.active) { e.preventDefault(); swipe.dx = Math.max(-120, Math.min(120, dx)) }
|
||||
}
|
||||
function swipeEnd (e, j) {
|
||||
if (swipe.name == null) return
|
||||
const active = swipe.active; const dx = swipe.dx
|
||||
swipe.name = null; swipe.active = false; swipe.dx = 0
|
||||
if (!active) return
|
||||
swipeGuard = true; setTimeout(() => { swipeGuard = false }, 380)
|
||||
if (Math.abs(dx) > 55) ctx.toggleSel(j)
|
||||
}
|
||||
function swipeReset () { swipe.name = null; swipe.active = false; swipe.dx = 0 }
|
||||
function onJobClick (j) { if (swipeGuard) { swipeGuard = false; return } ctx.openJobSheet(j) }
|
||||
function swipeStyle (j) { return (swipe.active && swipe.name === j.name) ? { transform: 'translateX(' + swipe.dx + 'px)' } : {} }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="variant === 'mobile'">
|
||||
<div class="pm-pool-hd"><q-icon name="inbox" size="16px" class="q-mr-xs" />À assigner <span class="pm-count">{{ filteredJobs.length }}</span><q-space /><span class="text-grey-5 q-mr-sm" style="font-size:10px;display:inline-flex;align-items:center;gap:1px">prio<i class="pm-leg" style="background:#ef4444"></i><i class="pm-leg" style="background:#f59e0b"></i><i class="pm-leg" style="background:#9e9e9e"></i></span>
|
||||
<q-select :model-value="sort" @update:model-value="v => sort = v" :options="MOBILE_SORTS" dense options-dense borderless emit-value map-options class="pm-sort"><template #prepend><q-icon name="sort" size="15px" color="grey-6" /></template></q-select>
|
||||
</div>
|
||||
<div v-if="skillChips.length" class="pm-chips">
|
||||
<button v-for="s in skillChips" :key="s.k" type="button" class="pm-chip" :class="{ on: skillFilter.includes(s.k) }" :style="skillFilter.includes(s.k) ? { background: ctx.getTagColor(s.k), borderColor: ctx.getTagColor(s.k), color: '#fff' } : {}" @click="pool.toggleSkill(s.k)">{{ s.k }} {{ s.n }}</button>
|
||||
<button v-if="skillFilter.length" type="button" class="pm-chip pm-chip-x" @click="pool.clearSkills()">✕ tout</button>
|
||||
</div>
|
||||
<div class="pm-pool-list">
|
||||
<template v-for="grp in groups" :key="grp.key">
|
||||
<div v-if="grp.label" class="pm-sector-hd" @click="ctx.selectAllSector(grp.key)"><q-icon name="place" size="13px" color="green-5" />{{ grp.key }}<q-icon name="done_all" size="14px" color="primary" class="q-ml-xs"><q-tooltip class="bg-grey-9">Sélectionner / désélectionner tout ce secteur (puis « Assigner »)</q-tooltip></q-icon><span style="margin-left:auto;font-weight:400;color:#94a3b8">{{ grp.n }} · {{ ctx.fmtMin(grp.mins || 0) }}</span></div>
|
||||
<div v-for="j in grp.jobs" :key="j.name" class="pm-swipe">
|
||||
<div class="pm-swipe-bg"><span class="pm-swipe-hint l"><q-icon name="check_circle" size="17px" /> Sélectionner</span><span class="pm-swipe-hint r">Sélectionner <q-icon name="check_circle" size="17px" /></span></div>
|
||||
<button type="button" class="pm-job" :class="{ sel: ctx.selectedJobs[j.name], hold: j.status === 'On Hold', swiping: swipe.active && swipe.name === j.name }" :style="{ borderLeft: '4px solid ' + ctx.prioColor(j.priority), ...swipeStyle(j) }"
|
||||
@click="onJobClick(j)" @pointerdown="swipeStart($event, j)" @pointermove="swipeMove($event)" @pointerup="swipeEnd($event, j)" @pointercancel="swipeReset">
|
||||
<q-icon :name="ctx.selectedJobs[j.name] ? 'check_circle' : (ctx.jobIsOnsite(j) ? 'home_repair_service' : 'cloud')" size="16px" :color="ctx.selectedJobs[j.name] ? 'primary' : (ctx.jobIsOnsite(j) ? 'teal' : 'grey-5')" class="q-mr-xs" />
|
||||
<span class="pm-job-main">
|
||||
<span class="pm-job-t ellipsis">{{ j.subject || j.service_type || j.name }}</span>
|
||||
<span class="pm-job-s ellipsis">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template></span>
|
||||
<span v-if="pool.jobAddrBadge(j)" class="pm-addr" :class="{ multi: pool.jobAddrBadge(j).multi }"><q-icon :name="pool.jobAddrBadge(j).multi ? 'workspaces' : 'place'" size="11px" />{{ pool.jobAddrBadge(j).n }} jobs<q-tooltip class="bg-grey-9">{{ pool.jobAddrBadge(j).n }} job(s) à cette adresse<template v-if="pool.jobAddrBadge(j).multi"> — nécessite un tech avec <b>{{ pool.jobAddrBadge(j).skills.join(' + ') }}</b></template>.</q-tooltip></span>
|
||||
<span v-if="j.scheduled_date || j.creation" class="pm-job-d">
|
||||
<span v-if="j.scheduled_date" :class="ctx.isOverdue(j.scheduled_date) ? 'text-warning text-weight-medium' : 'text-grey-6'">📅 {{ ctx.fmtDueLabel(j.scheduled_date) }}</span>
|
||||
<span v-if="j.creation" class="text-grey-5"> · créé {{ String(j.creation).slice(0, 10) }}</span>
|
||||
</span>
|
||||
<span v-if="j.notes" class="pm-job-note ellipsis"><q-icon name="sticky_note_2" size="12px" color="warning" /> {{ j.notes }}</span>
|
||||
</span>
|
||||
<span class="pm-job-icons">
|
||||
<q-icon :name="j.priority === 'high' ? 'star' : 'star_border'" :color="j.priority === 'high' ? 'amber-7' : 'grey-5'" size="21px" @click.stop="ctx.toggleUrgent(j)"><q-tooltip>{{ j.priority === 'high' ? 'Urgent — toucher pour retirer' : 'Marquer urgent (priorité, heure fixée / SLA)' }}</q-tooltip></q-icon>
|
||||
<q-icon :name="j.notes ? 'sticky_note_2' : 'note_add'" :color="j.notes ? 'deep-orange-6' : 'grey-4'" size="19px" @click.stop="ctx.openJobSheet(j, { note: true })"><q-tooltip>Note importante / détails</q-tooltip></q-icon>
|
||||
<q-icon name="more_vert" color="grey-6" size="19px" @click.stop="ctx.openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
|
||||
</span>
|
||||
<span class="pm-job-h">{{ j.est_min ? ctx.fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!filteredJobs.length" class="pm-empty">Rien à assigner 🎉</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="variant === 'docked'">
|
||||
<div class="kbb-pool-hd"><q-icon name="inbox" size="15px" /> À assigner <span class="kb-count">{{ filteredJobs.length }}</span></div>
|
||||
<div class="kbb-pool-tools">
|
||||
<q-input dense outlined :model-value="search" @update:model-value="v => search = v || ''" placeholder="Rechercher…" clearable><template #prepend><q-icon name="search" size="16px" /></template></q-input>
|
||||
<q-select dense outlined :model-value="sort" @update:model-value="v => sort = v" emit-value map-options :options="DOCKED_SORTS"><template #prepend><q-icon name="sort" size="16px" /></template></q-select>
|
||||
<div v-if="skillChips.length > 1" class="kb-chips">
|
||||
<button v-for="s in skillChips" :key="s.k" type="button" class="kb-chip" :class="{ on: skillFilter.includes(s.k) }" :style="skillFilter.includes(s.k) ? { background: ctx.getTagColor(s.k), borderColor: ctx.getTagColor(s.k), color: '#fff' } : {}" @click="pool.toggleSkill(s.k)">{{ s.k }} {{ s.n }}</button>
|
||||
<button v-if="skillFilter.length" type="button" class="kb-chip kb-chip-x" @click="pool.clearSkills()">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kbb-pool-body">
|
||||
<template v-for="grp in groups" :key="grp.key">
|
||||
<div v-if="grp.label" class="kb-grp-hd">{{ grp.label }} <span style="opacity:.6">({{ grp.n }})</span></div>
|
||||
<div v-for="j in grp.jobs" :key="j.name" class="kb-card" :class="{ hold: j.status === 'On Hold', dragging: ctx.draggingSet.has(j.name) }" :style="{ borderLeftColor: kbColor(j) }" :draggable="j.status !== 'On Hold'" @dragstart="ctx.onJobDragStart($event, j)" @dragend="ctx.onJobDragEnd">
|
||||
<div class="kb-card-t"><q-icon :name="skillSym(j.required_skill)" size="13px" :style="{ color: kbColor(j) }" /> {{ j.subject || j.service_type || j.name }}</div>
|
||||
<div class="kb-card-m"><span class="kb-dot" :style="{ background: ctx.prioColor(j.priority) }"></span>{{ j.est_min ? ctx.fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}<template v-if="ctx.jobCity(j)"> · {{ ctx.jobCity(j) }}</template><span v-if="pool.jobAddrBadge(j)" class="kb-addr" :class="{ multi: pool.jobAddrBadge(j).multi }"><q-icon :name="pool.jobAddrBadge(j).multi ? 'workspaces' : 'place'" size="10px" />{{ pool.jobAddrBadge(j).n }}</span></div>
|
||||
<q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px">{{ j.subject || j.service_type }}<template v-if="j.customer_name"><br>{{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template><template v-if="j.required_skill"><br>🧩 {{ j.required_skill }}</template><template v-if="pool.jobAddrBadge(j) && pool.jobAddrBadge(j).multi"><br>👥 {{ pool.jobAddrBadge(j).n }} jobs à cette adresse — tech avec {{ pool.jobAddrBadge(j).skills.join(' + ') }}</template><template v-if="j.status === 'On Hold'"><br>🔒 en attente — non assignable</template></q-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="!filteredJobs.length" class="kb-empty">— rien à assigner —</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="assign-sortbar" @mousedown.stop>
|
||||
<span>Trier :</span>
|
||||
<select :value="sort" @change="e => sort = e.target.value" @mousedown.stop>
|
||||
<option value="group">Groupe (parent-enfant)</option>
|
||||
<option value="skill">Compétence</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="city">Ville</option>
|
||||
<option value="priority">Priorité</option>
|
||||
</select>
|
||||
<q-btn dense flat size="sm" :icon="sortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'" @click="sortDir = sortDir === 'asc' ? 'desc' : 'asc'" @mousedown.stop><q-tooltip>{{ sortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}</q-tooltip></q-btn>
|
||||
<q-btn dense flat size="sm" :icon="allCollapsed ? 'unfold_more' : 'unfold_less'" no-caps @click="pool.toggleCollapseAll()" @mousedown.stop><q-tooltip>{{ allCollapsed ? 'Tout déplier' : 'Tout replier (voir un seul groupe)' }}</q-tooltip></q-btn>
|
||||
<q-space />
|
||||
<q-btn v-if="ctx.selectedNames.value.length" dense flat size="sm" icon="deselect" :label="'✕ ' + ctx.selectedNames.value.length" no-caps color="grey-7" @click="ctx.clearSel()" @mousedown.stop><q-tooltip>Tout désélectionner</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div v-if="skillChips.length > 1" class="assign-chips" @mousedown.stop>
|
||||
<span v-for="t in skillChips" :key="t.k" class="assign-chip-f" :style="skillFilter.includes(t.k) ? { background: ctx.getTagColor(t.k), color: '#fff', borderColor: ctx.getTagColor(t.k) } : { borderColor: ctx.getTagColor(t.k) }" @click="pool.toggleSkill(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
|
||||
<span v-if="skillFilter.length" class="assign-chip-f clear" @click="pool.clearSkills()"><q-tooltip>Tout afficher</q-tooltip>✕</span>
|
||||
</div>
|
||||
<div v-if="dateChips.length > 1" class="assign-chips" @mousedown.stop>
|
||||
<q-icon name="event" size="14px" color="grey-6" class="q-mr-xs" />
|
||||
<span v-for="d in dateChips" :key="d.iso" class="assign-chip-f" :style="dateFilter.includes(d.iso) ? { background: d.overdue ? '#f59e0b' : '#6366f1', color: '#fff', borderColor: d.overdue ? '#f59e0b' : '#6366f1' } : (d.overdue ? { borderColor: '#f59e0b', color: '#b45309' } : {})" @click="pool.toggleDate(d.iso)">{{ d.label }} <b>{{ d.n }}</b></span>
|
||||
<span v-if="dateFilter.length" class="assign-chip-f clear" @click="pool.clearDates()"><q-tooltip>Toutes les dates</q-tooltip>✕</span>
|
||||
</div>
|
||||
<div class="assign-body">
|
||||
<div v-if="!filteredJobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
|
||||
<div v-for="grp in groups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': ctx.groupSelected(grp) }">
|
||||
<div v-if="grp.label" class="assign-grp-lbl" style="cursor:pointer;display:flex;align-items:center;gap:3px" @click="pool.toggleCollapse(grp.key)"><q-icon :name="collapsed.has(grp.key) ? 'chevron_right' : 'expand_more'" size="15px" /><span>{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></span><q-space /><q-icon name="done_all" size="16px" :color="ctx.groupSelected(grp) ? 'primary' : 'grey-5'" @click.stop="ctx.toggleGroupSelAll(grp)"><q-tooltip>Sélectionner / désélectionner tout ce groupe</q-tooltip></q-icon></div>
|
||||
<template v-if="!(grp.label && collapsed.has(grp.key))">
|
||||
<div v-if="sort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="ctx.toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
||||
<div v-for="(j, idx) in grp.jobs" :key="j.name" :data-jobname="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: sort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!ctx.selectedJobs[j.name], dragging: ctx.draggingSet.has(j.name), 'job-flash': ctx.flashJob.value === j.name, assoc: pool.jobAssocOnly(j) }" :style="{ borderLeft: '5px solid ' + ctx.panelJobColor(j) }" draggable="true" @dragstart="ctx.onJobDragStart($event, j)" @dragend="ctx.onJobDragEnd">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-checkbox dense size="xs" :model-value="!!ctx.selectedJobs[j.name]" @update:model-value="v => { if (v) ctx.selectedJobs[j.name] = true; else delete ctx.selectedJobs[j.name] }" @click.stop @mousedown.stop class="q-mr-xs" />
|
||||
<q-icon :name="ctx.jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="ctx.jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ ctx.jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
|
||||
<span v-if="ctx.groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ ctx.groupLabel(j)[0] }}) — simple repère de proximité</q-tooltip>{{ ctx.groupLabel(j) }}</span>
|
||||
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
|
||||
<span class="ellipsis text-weight-medium" :style="ctx.showMap.value ? 'cursor:pointer' : ''" @click="ctx.showMap.value && ctx.focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
|
||||
<span v-if="ctx.jobSlaBadge(j)" class="assign-sla" :class="'sla-' + ctx.jobSlaBadge(j).state"><q-icon :name="ctx.jobSlaBadge(j).icon" size="11px" />{{ ctx.jobSlaBadge(j).short }}<q-tooltip class="bg-grey-9">{{ ctx.jobSlaBadge(j).label }}</q-tooltip></span>
|
||||
<q-space />
|
||||
<q-btn v-if="j.status !== 'On Hold'" flat dense round size="sm" icon="person_add" color="primary" class="q-ml-xs" @click.stop @mousedown.stop>
|
||||
<q-tooltip>Assigner à un technicien (classés par pertinence : compétence · dispo · distance · charge)</q-tooltip>
|
||||
<q-menu anchor="bottom right" self="top right" @click.stop>
|
||||
<div class="assign-pick-hd">Assigner « {{ (j.subject || j.name).slice(0, 30) }} » · 📅 {{ ctx.fmtDueLabel(ctx.jobTargetDay(j)) }}</div>
|
||||
<q-list dense style="min-width:270px;max-height:44vh;overflow:auto">
|
||||
<q-item v-for="tk in ctx.techsForJob(j)" :key="tk.id" clickable v-close-popup @click="ctx.quickAssign(j, tk)" :class="{ 'assign-pick-incap': !tk.capable }">
|
||||
<q-item-section avatar style="min-width:32px"><q-avatar size="26px" :color="tk.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:10px;font-weight:700">{{ ctx.initials(tk.name) }}</q-avatar></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ tk.name }}<q-icon v-if="!tk.capable && j.required_skill" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Compétence « {{ j.required_skill }} » absente</q-tooltip></q-icon></q-item-label>
|
||||
<q-item-label caption><span v-if="tk.distKm != null">📍 {{ Math.round(tk.distKm) }} km</span><span v-else class="text-grey-5">dist. ?</span> · {{ tk.h }}/{{ tk.cap }}h<span v-if="tk.noShift" class="text-warning"> · ▲ sans quart</span><span v-else-if="tk.over" class="text-negative"> · surchargé</span></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side><q-icon name="chevron_right" size="16px" color="grey-5" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="!ctx.techsForJob(j).length"><q-item-section class="text-grey-6 text-caption">Aucun technicien visible.</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-icon v-if="j.legacy_ticket_id" name="forum" size="14px" :color="j._showThread ? 'indigo' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop="ctx.toggleAssignThread(j)" @mousedown.stop><q-tooltip>Voir le fil du ticket #{{ j.legacy_ticket_id }}</q-tooltip></q-icon>
|
||||
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
|
||||
</div>
|
||||
<div class="assign-sub">
|
||||
<span v-if="j.required_skill" class="assign-skill" :style="{ background: ctx.getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
|
||||
<span v-if="pool.jobAssocOnly(j)" class="assign-linked"><q-icon name="link" size="11px" />lié<q-tooltip class="bg-grey-9">Affiché car un autre job filtré est à la MÊME adresse — à dispatcher avec lui (même tech).</q-tooltip></span>
|
||||
<span v-if="pool.jobAddrBadge(j)" class="assign-addr" :class="{ multi: pool.jobAddrBadge(j).multi }"><q-icon :name="pool.jobAddrBadge(j).multi ? 'workspaces' : 'place'" size="11px" />{{ pool.jobAddrBadge(j).n }} jobs<q-tooltip class="bg-grey-9">{{ pool.jobAddrBadge(j).n }} job(s) à cette adresse<template v-if="pool.jobAddrBadge(j).multi"> — nécessite un tech avec <b>{{ pool.jobAddrBadge(j).skills.join(' + ') }}</b></template>.</q-tooltip></span>
|
||||
<span v-if="j.required_skill" class="assign-lvl" :class="{ set: j.required_level > 1 }" @click.stop @mousedown.stop><q-icon name="military_tech" size="11px" />niv {{ j.required_level || 1 }}<q-tooltip class="bg-grey-9">Niveau min requis pour ce job (persistant · mode « juste ce qu'il faut » → réserve les experts)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:150px"><q-item v-for="n in [1,2,3,4,5]" :key="n" clickable v-close-popup @click="ctx.setJobReqLevel(j, n)" :active="(j.required_level||1)===n"><q-item-section>Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }}</q-item-section></q-item></q-list></q-menu></span>
|
||||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-positive text-weight-medium">≈ {{ j.est_min ? ctx.fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date"> · 📅 <span :class="ctx.isOverdue(j.scheduled_date) ? 'text-warning text-weight-bold' : 'text-grey-7'">{{ ctx.fmtDueLabel(j.scheduled_date) }}</span><q-icon name="edit_calendar" size="14px" :color="ctx.isOverdue(j.scheduled_date) ? 'warning' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop @mousedown.stop><q-tooltip>Replanifier (aujourd'hui / demain / date)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="ctx.setJobDate(j, ctx.todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="ctx.setJobDate(j, ctx.tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="j.scheduled_date" @update:model-value="v => ctx.setJobDate(j, v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-icon></span>
|
||||
</div>
|
||||
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
|
||||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
|
||||
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
|
||||
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="assign-msg"><b>{{ m.author }}</b><span v-if="m.at" class="text-grey-5"> · {{ ctx.fmtDT(m.at) }}</span><div class="assign-msg-txt">{{ m.text }}</div></div>
|
||||
</template>
|
||||
<div v-else class="text-grey-6" style="font-size:11px;white-space:pre-wrap">{{ j.legacy_detail || 'Aucun fil pour ce ticket.' }}</div>
|
||||
<div class="text-right q-mt-xs"><q-btn dense flat size="sm" color="negative" icon="block" label="Fermer ce ticket (legacy)" @click="ctx.closeLegacyTicket(j)"><q-tooltip>Ticket inutile → status closed dans osTicket + sort du dispatch</q-tooltip></q-btn></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Styles NON scopés (préfixes assign-/kb-/pm- = pas de collision) — le composant est monté DANS la page dont le <style scoped> ne l'atteint pas. -->
|
||||
<style>
|
||||
/* ── FLOATING ── */
|
||||
.assign-sortbar { display: flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; color: #555; background: #f3f0fa; border-bottom: 1px solid #e0e0e0; }
|
||||
.assign-sortbar select { font-size: 11px; border: 1px solid #cfc4e8; border-radius: 5px; padding: 1px 4px; background: #fff; color: #333; flex: 1; }
|
||||
.assign-body { overflow: auto; padding: 5px; flex: 1 1 auto; min-height: 60px; }
|
||||
.assign-chips { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 6px; border-bottom: 1px solid #eee; flex: 0 0 auto; }
|
||||
.assign-chip-f { font-size: 10px; border: 1.5px solid #cfd8dc; border-radius: 11px; padding: 1px 8px; cursor: pointer; user-select: none; line-height: 1.6; background: #fff; color: #455a64; }
|
||||
.assign-chip-f.clear { border-color: #b0bec5; color: #607d8b; font-weight: 700; }
|
||||
.assign-chip-f:hover { filter: brightness(0.95); }
|
||||
.assign-grp-badge { display: inline-block; background: #cfd8dc; color: #455a64; font-weight: 700; border-radius: 4px; padding: 0 5px; font-size: 10px; line-height: 16px; }
|
||||
.assign-grp { margin-bottom: 6px; border-radius: 7px; padding: 2px; }
|
||||
.assign-grp-lbl { font-size: 11px; font-weight: 700; color: #37474f; padding: 3px 6px 2px; border-bottom: 1px solid #eee; margin-bottom: 2px; position: sticky; top: 0; background: #fff; z-index: 1; }
|
||||
.assign-grp.grp-hl { background: #ede7f6; box-shadow: inset 0 0 0 1px #b39ddb; }
|
||||
.assign-grp-hdr { font-size: 10px; font-weight: 700; color: #5e35b1; padding: 2px 6px; cursor: pointer; display: flex; align-items: center; gap: 3px; }
|
||||
.assign-grp-hdr:hover { text-decoration: underline; }
|
||||
.assign-job { border: 1px solid #e0e0e0; border-radius: 6px; padding: 3px 7px; margin: 3px 0; cursor: grab; background: #fafafa; font-size: 12px; }
|
||||
.assign-job:hover { border-color: #5e35b1; background: #f3e9fb; }
|
||||
.assign-job:active { cursor: grabbing; }
|
||||
.assign-job.sel { border-color: #00897b; background: #e0f2f1; box-shadow: inset 0 0 0 1px #00897b; }
|
||||
.assign-job.child { margin-left: 14px; border-left: 3px solid #b39ddb; }
|
||||
.assign-job.blocked { opacity: .65; }
|
||||
.assign-job.assoc { background: #fbfbfd; border-style: dashed; }
|
||||
.assign-linked { display: inline-flex; align-items: center; gap: 1px; color: #6d4c9f; background: #ede7f6; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
|
||||
.assign-addr { display: inline-flex; align-items: center; gap: 1px; color: #37474f; background: #eceff1; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
|
||||
.assign-addr.multi { color: #fff; background: #7e57c2; }
|
||||
.assign-job.job-flash { animation: jobflash 1.7s ease-out; }
|
||||
@keyframes jobflash { 0% { background: #fff59d; box-shadow: inset 0 0 0 2px #fbc02d; } 100% { background: #fafafa; box-shadow: none; } }
|
||||
.assign-sub { font-size: 10px; color: #888; margin-top: 1px; }
|
||||
.assign-pick-hd { font-size: 11px; font-weight: 700; color: #455a64; padding: 6px 10px; border-bottom: 1px solid #eef2f7; background: #f7f5fc; }
|
||||
.assign-pick-incap { opacity: 0.62; }
|
||||
.assign-skill { display: inline-block; color: #fff; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; margin-right: 3px; }
|
||||
.assign-lvl { display: inline-flex; align-items: center; gap: 1px; border: 1px solid #cfd8dc; border-radius: 6px; padding: 0 5px; font-size: 9px; font-weight: 600; color: #607d8b; margin-right: 3px; cursor: pointer; }
|
||||
.assign-lvl.set { border-color: #6366f1; color: #4338ca; background: #eef2ff; }
|
||||
.assign-sla { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: 8px; white-space: nowrap; margin-left: 4px; }
|
||||
.assign-sla.sla-breached { background: #fee2e2; color: #b91c1c; }
|
||||
.assign-sla.sla-at_risk { background: #ffedd5; color: #c2410c; }
|
||||
.assign-thread { background: #f5f5f5; border-radius: 5px; padding: 4px 7px; margin-top: 4px; max-height: 220px; overflow: auto; }
|
||||
.assign-msg { font-size: 11px; border-top: 1px solid #e0e0e0; padding: 3px 0; }
|
||||
.assign-msg:first-child { border-top: none; }
|
||||
.assign-msg-txt { white-space: pre-wrap; color: #37474f; }
|
||||
/* ── DOCKED (kanban) ── */
|
||||
.kbb-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #2b3445; padding: 7px 9px 4px; }
|
||||
.kbb-pool-tools { display: flex; flex-direction: column; gap: 5px; padding: 0 7px 7px; border-bottom: 1px solid #e0e6ef; }
|
||||
.kbb-pool-body { flex: 1 1 auto; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.kb-count { margin-left: auto; background: #d7deea; color: #3a4658; border-radius: 9px; padding: 0 7px; font-size: 11px; }
|
||||
.kb-chips { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.kb-chip { font-size: 10px; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 999px; padding: 1px 8px; cursor: pointer; white-space: nowrap; }
|
||||
.kb-chip.on { color: #fff; border-color: transparent; }
|
||||
.kb-chip-x { border-color: #fca5a5; color: #dc2626; font-weight: 700; }
|
||||
.kb-grp-hd { font-size: 10.5px; font-weight: 700; color: #475569; padding: 3px 4px 1px; position: sticky; top: 0; background: #eef2f7; z-index: 1; }
|
||||
.kb-card { background: #fff; border: 1px solid #e6e9ef; border-left: 4px solid #90a4ae; border-radius: 6px; padding: 5px 7px; cursor: grab; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||||
.kb-card:hover { box-shadow: 0 2px 6px rgba(0,0,0,.13); }
|
||||
.kb-card:active { cursor: grabbing; }
|
||||
.kb-card.dragging { opacity: .4; }
|
||||
.kb-card.hold { opacity: .62; cursor: not-allowed; background: repeating-linear-gradient(45deg, #fafafa 0 6px, #f0f0f0 6px 12px); }
|
||||
.kb-card-t { font-size: 12px; font-weight: 600; color: #222; line-height: 1.25; display: flex; align-items: center; }
|
||||
.kb-card-m { font-size: 10.5px; color: #67707e; display: flex; align-items: center; gap: 4px; margin-top: 2px; }
|
||||
.kb-dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; }
|
||||
.kb-addr { display: inline-flex; align-items: center; gap: 1px; color: #37474f; background: #eceff1; border-radius: 5px; padding: 0 3px; font-size: 9px; font-weight: 700; }
|
||||
.kb-addr.multi { color: #fff; background: #7e57c2; }
|
||||
.kb-empty { font-size: 11px; color: #9aa3b0; text-align: center; padding: 10px 4px; border: 1px dashed #d7deea; border-radius: 6px; }
|
||||
/* ── MOBILE ── */
|
||||
.pm-pool-hd { display: flex; align-items: center; font-size: 12px; font-weight: 700; color: #475569; margin-bottom: 4px; }
|
||||
.pm-count { margin-left: 6px; background: #e2e8f0; color: #475569; border-radius: 9px; font-size: 11px; padding: 0 7px; font-weight: 700; }
|
||||
.pm-leg { width: 7px; height: 7px; border-radius: 2px; display: inline-block; margin-left: 2px; }
|
||||
.pm-sort { font-size: 11px; min-height: 0; max-width: 120px; }
|
||||
.pm-sort .q-field__control { min-height: 26px; }
|
||||
.pm-sort .q-field__native { font-size: 11px; font-weight: 600; color: #475569; padding: 0; min-height: 26px; }
|
||||
.pm-sort .q-field__prepend { padding-right: 2px; height: 26px; }
|
||||
.pm-sort .q-field__marginal { height: 26px; }
|
||||
.pm-chips { display: flex; gap: 5px; overflow-x: auto; padding-bottom: 6px; margin-bottom: 2px; }
|
||||
.pm-chips::-webkit-scrollbar { height: 0; }
|
||||
.pm-chip { flex: 0 0 auto; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 999px; font-size: 11px; font-weight: 600; padding: 3px 10px; cursor: pointer; white-space: nowrap; }
|
||||
.pm-chip.on { color: #fff; border-color: transparent; }
|
||||
.pm-chip-x { border-color: #fca5a5; color: #dc2626; }
|
||||
.pm-pool-list { max-height: 46vh; overflow-y: auto; }
|
||||
.pm-sector-hd { display: flex; align-items: center; gap: 4px; padding: 5px 8px; margin: 6px 0 2px; background: #eef2f7; border-radius: 6px; font-size: 11px; font-weight: 600; color: #475569; position: sticky; top: 0; z-index: 2; cursor: pointer; }
|
||||
.pm-swipe { position: relative; margin-bottom: 6px; border-radius: 8px; overflow: hidden; }
|
||||
.pm-swipe-bg { position: absolute; inset: 0; display: flex; align-items: center; justify-content: space-between; border-radius: 8px; background: linear-gradient(90deg, #bbf7d0 0 50%, #c7d2fe 50% 100%); }
|
||||
.pm-swipe-hint { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 800; padding: 0 14px; }
|
||||
.pm-swipe-hint.l { color: #166534; }
|
||||
.pm-swipe-hint.r { color: #3730a3; }
|
||||
.pm-swipe .pm-job { margin-bottom: 0; position: relative; touch-action: pan-y; transition: transform .18s ease; }
|
||||
.pm-job { display: flex; align-items: center; gap: 6px; width: 100%; text-align: left; border: 1px solid #e2e8f0; background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 6px; cursor: pointer; }
|
||||
.pm-job.sel { border-color: #6366f1; background: #eef2ff; box-shadow: 0 0 0 2px rgba(99, 102, 241, .18); }
|
||||
.pm-job.hold { background: repeating-linear-gradient(45deg, #f8fafc 0 7px, #eef2f7 7px 14px); cursor: not-allowed; }
|
||||
.pm-job.hold .pm-job-t { color: #64748b; }
|
||||
.pm-job.swiping { transition: none; }
|
||||
.pm-job-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.pm-job-t { font-size: 13px; font-weight: 600; color: #1e293b; }
|
||||
.pm-job-s { font-size: 11px; color: #64748b; }
|
||||
.pm-job-d { font-size: 10.5px; margin-top: 1px; }
|
||||
.pm-job-note { font-size: 10.5px; margin-top: 1px; color: #c2410c; font-style: italic; max-width: 100%; }
|
||||
.pm-job-icons { flex: 0 0 auto; display: flex; align-items: center; gap: 1px; }
|
||||
.pm-job-icons .q-icon { padding: 2px; cursor: pointer; }
|
||||
.pm-job-h { flex: 0 0 auto; font-size: 12px; font-weight: 700; color: #0d9488; min-width: 30px; text-align: right; }
|
||||
.pm-addr { display: inline-flex; align-items: center; gap: 1px; align-self: flex-start; color: #37474f; background: #eceff1; border-radius: 5px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-top: 1px; }
|
||||
.pm-addr.multi { color: #fff; background: #7e57c2; }
|
||||
.pm-empty { color: #94a3b8; font-size: 13px; text-align: center; padding: 12px; }
|
||||
</style>
|
||||
|
|
@ -31,6 +31,12 @@
|
|||
</div>
|
||||
</q-expansion-item>
|
||||
<q-separator />
|
||||
<!-- Job « bouche-trou » : occuper la journée du tech (formation, réunion, admin…) → le retire du dispatch -->
|
||||
<q-item clickable dense @click="$emit('filler')">
|
||||
<q-item-section avatar style="min-width:30px"><q-icon name="block" color="deep-orange-6" /></q-item-section>
|
||||
<q-item-section><q-item-label>Bloquer / job générique…</q-item-label><q-item-label caption>Occupe la journée (durée éditable) · retire du dispatch · ticket</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
<q-separator />
|
||||
<!-- Shifts en place + actions compactes -->
|
||||
<q-item v-for="a in shifts" :key="'c' + (a.shift || a.name)" dense>
|
||||
<q-item-section>{{ a.shift_name || a.shift }} <span class="text-grey-6">{{ a.hours }}h</span></q-item-section>
|
||||
|
|
@ -58,7 +64,7 @@ const props = defineProps({
|
|||
cellKey: { type: String, default: '' }, // change à chaque (ré)ouverture → réinitialise saisie/slider
|
||||
showCopy: { type: Boolean, default: true },
|
||||
})
|
||||
const emit = defineEmits(['window', 'toggle-garde', 'toggle-absent', 'remove-shift', 'clear', 'copy', 'paste'])
|
||||
const emit = defineEmits(['window', 'toggle-garde', 'toggle-absent', 'remove-shift', 'clear', 'copy', 'paste', 'filler'])
|
||||
const quickEntry = ref('')
|
||||
const range = ref({ min: props.initialRange.min, max: props.initialRange.max })
|
||||
// Réinitialise à chaque ouverture sur une nouvelle cellule (le composant reste monté, seul le q-menu parent s'affiche/masque).
|
||||
|
|
|
|||
97
apps/ops/src/components/planif/SkillCadenceTable.vue
Normal file
97
apps/ops/src/components/planif/SkillCadenceTable.vue
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
<script setup>
|
||||
/**
|
||||
* SkillCadenceTable — éditeur RÉUTILISABLE des compétences d'un technicien :
|
||||
* chips (ordre = priorité, glisser-déposer) + Score (★ maîtrise) + Cadence % PAR compétence,
|
||||
* et (optionnel) la Cadence GLOBALE du tech (défaut 100 %).
|
||||
*
|
||||
* SOURCE UNIQUE : utilisé à la fois par le volet « clic sur un tech » (Planif) ET par l'outil
|
||||
* « Équipe — compétences, cadence & coût » → même gabarit partout. Opère sur l'objet tech LIVE
|
||||
* (skills[]/skill_levels{}/skill_eff{}/efficiency) ; la persistance reste au parent via les events.
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||||
|
||||
const props = defineProps({
|
||||
tech: { type: Object, required: true }, // objet LIVE : { id, name, skills[], skill_levels{}, skill_eff{}, efficiency }
|
||||
catalog: { type: Array, default: () => [] }, // tagCatalog
|
||||
getColor: { type: Function, default: () => '#6b7280' },
|
||||
palette: { type: Array, default: () => [] }, // TAG_PALETTE
|
||||
showGlobal: { type: Boolean, default: false }, // affiche la cadence GLOBALE (défaut 100 %) — utile dans l'outil équipe
|
||||
})
|
||||
const emit = defineEmits(['tags-change', 'set-level', 'set-cadence', 'set-global', 'create-tag', 'update-tag'])
|
||||
|
||||
// Conversions cadence % ↔ facteur (bas facteur = rapide). Mêmes formules que PlanificationPage (effPctOf/factorFromPct).
|
||||
const pctOf = (factor) => { const f = Number(factor) || 1; return Math.round(100 / (f > 0 ? f : 1)) }
|
||||
|
||||
const skills = () => (Array.isArray(props.tech.skills) ? props.tech.skills : [])
|
||||
const levelOf = (sk) => (props.tech.skill_levels && props.tech.skill_levels[sk]) || 0
|
||||
const cadenceOf = (sk) => { const e = props.tech.skill_eff && props.tech.skill_eff[sk]; return (e != null && e !== '') ? pctOf(e) : '' } // '' = hérite du global
|
||||
const globalPct = () => pctOf(props.tech.efficiency) // défaut efficiency=1 → 100 %
|
||||
|
||||
// Tampons de saisie (n'écrit qu'au blur/enter, comme l'original) — locaux au composant.
|
||||
const buf = reactive({}) // { sk: pct }
|
||||
const gBuf = reactive({ v: undefined })
|
||||
function commitSkill (sk) { if (!(sk in buf)) return; const v = buf[sk]; delete buf[sk]; emit('set-cadence', { sk, pct: v }) }
|
||||
function commitGlobal () { if (gBuf.v === undefined) return; const v = gBuf.v; gBuf.v = undefined; emit('set-global', v) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<TagEditor :model-value="skills()" :all-tags="catalog" :get-color="getColor" :can-edit="false" sortable
|
||||
placeholder="Cliquez pour ajouter une compétence…"
|
||||
@update:model-value="items => emit('tags-change', items)" @create="e => emit('create-tag', e)" />
|
||||
<div v-if="skills().length > 1" class="text-caption text-grey-6 q-mt-xs" style="line-height:1.3">
|
||||
↔ <b>Glisse les chips</b> pour l'ordre de priorité — la <b>1<sup>re</sup> (★)</b> = fonction principale :
|
||||
le dispatch auto envoie ces jobs à ce tech <b>en premier</b> (épargne les moins spécialisés).
|
||||
</div>
|
||||
|
||||
<div v-if="skills().length" class="q-mt-md">
|
||||
<div class="row items-center text-caption text-grey-6 q-pb-xs">
|
||||
<div class="col">Compétence</div>
|
||||
<div style="width:90px" class="text-center">Score</div>
|
||||
<div style="width:88px" class="text-center">Cadence <HelpHint title="Cadence (vitesse)" text="Vitesse du technicien pour cette compétence. 100 % = cadence normale ; plus haut = plus rapide (fait plus de jobs, ex. 200 % = deux fois plus) ; sous 100 % = plus lent. Vide = hérite de la cadence globale du tech. Le dispatch auto en tient compte pour estimer les durées." /></div>
|
||||
</div>
|
||||
<div v-for="(sk, si) in skills()" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
|
||||
<div class="col row items-center no-wrap">
|
||||
<span class="skill-rank" :class="{ 'skill-rank-1': si === 0 }" :title="si === 0 ? 'Priorité 1 — compétence principale' : 'Priorité ' + (si + 1)">{{ si + 1 }}</span>
|
||||
<q-btn flat dense round size="xs" icon="circle" :style="{ color: getColor(sk) }"><q-tooltip>Couleur</q-tooltip>
|
||||
<q-menu><div class="q-pa-xs" style="width:208px">
|
||||
<div class="row">
|
||||
<q-btn v-for="c in palette" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="emit('update-tag', { name: sk, color: c })" />
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mt-xs q-px-xs">
|
||||
<span class="text-caption text-grey-7 q-mr-sm">Perso</span>
|
||||
<input type="color" :value="getColor(sk)" @change="e => emit('update-tag', { name: sk, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" />
|
||||
<span class="text-caption text-grey-5 q-ml-sm">toute couleur</span>
|
||||
</div>
|
||||
</div></q-menu>
|
||||
</q-btn>
|
||||
<span class="skill-chip" :style="{ background: getColor(sk) }">{{ sk }}</span>
|
||||
</div>
|
||||
<div style="width:90px" class="text-center no-wrap">
|
||||
<q-icon v-for="n in 5" :key="n" :name="levelOf(sk) >= n ? 'star' : 'star_outline'" :color="levelOf(sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="emit('set-level', { sk, level: levelOf(sk) === n ? 0 : n })" />
|
||||
</div>
|
||||
<div style="width:88px">
|
||||
<q-input dense outlined type="number" step="25" min="20" max="1000" :model-value="(sk in buf) ? buf[sk] : cadenceOf(sk)" @update:model-value="v => { buf[sk] = v }" @blur="commitSkill(sk)" @keyup.enter="commitSkill(sk)" suffix="%" placeholder="glob." input-class="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Cadence</b> : <b>100 %</b> = normale · <b>200 %</b> = deux fois plus de jobs · < 100 % = plus lent · vide = hérite du global · × sur le chip = retirer.</div>
|
||||
</div>
|
||||
|
||||
<!-- Cadence GLOBALE du tech (défaut 100 %) — s'applique aux compétences sans cadence propre. -->
|
||||
<div v-if="showGlobal" class="row items-center q-mt-sm q-pt-sm" style="border-top:1px dashed #e2e8f0">
|
||||
<div class="col text-caption text-weight-medium text-grey-7">Cadence globale <span class="text-grey-5 text-weight-regular">(défaut 100 %)</span></div>
|
||||
<q-input dense outlined type="number" step="10" min="20" max="1000" style="width:96px" suffix="%"
|
||||
:model-value="(gBuf.v !== undefined) ? gBuf.v : globalPct()" @update:model-value="v => { gBuf.v = v }" @blur="commitGlobal" @keyup.enter="commitGlobal">
|
||||
<q-tooltip>100 % = cadence normale · PLUS HAUT = plus rapide (fait plus de jobs). Hérité par les compétences sans cadence propre.</q-tooltip>
|
||||
</q-input>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skill-chip { font-size: 10px; line-height: 15px; height: 15px; padding: 0 5px; border-radius: 8px; color: #fff; font-weight: 600; white-space: nowrap; flex-shrink: 0; display: inline-flex; align-items: center; gap: 2px; }
|
||||
.skill-rank { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: #e2e8f0; color: #64748b; font-size: 9px; font-weight: 800; margin-right: 5px; flex-shrink: 0; }
|
||||
.skill-rank-1 { background: #fef3c7; color: #b45309; box-shadow: 0 0 0 1.5px rgba(250,204,21,0.7); }
|
||||
</style>
|
||||
|
|
@ -9,6 +9,16 @@
|
|||
– Deux bascules agissent sur MOI (l'utilisateur connecté) : m'ajouter comme assistant · suivre. -->
|
||||
<div class="asg-field">
|
||||
<div class="asg-chips row items-center q-gutter-xs">
|
||||
<HelpHint title="Assignation">
|
||||
Ajoutez des personnes à l'intervention. Le premier ajouté devient l'<b>assigné (À)</b> ; chacun a un niveau :
|
||||
<ul>
|
||||
<li><b>À</b> — technicien assigné (responsable).</li>
|
||||
<li><b>Assistant (CC)</b> — renfort sur l'équipe, <b>sans</b> bloc horaire réservé.</li>
|
||||
<li><b>Sur place</b> — assistant <b>avec</b> un bloc réservé dans son horaire.</li>
|
||||
<li><b>Suiveur (#)</b> — reçoit les mises à jour, sans place dans l'équipe.</li>
|
||||
</ul>
|
||||
Les deux bascules agissent sur vous : m'ajouter comme assistant · suivre.
|
||||
</HelpHint>
|
||||
<!-- À : lead / assigné -->
|
||||
<q-chip v-if="hasLead" dense removable @remove="$emit('unassign')" color="indigo-6" text-color="white" class="text-weight-bold asg-chip">
|
||||
<q-avatar color="indigo-9" text-color="white" size="20px">{{ ini(assignee.name || assignee.id) }}</q-avatar>
|
||||
|
|
@ -84,6 +94,7 @@ import { HUB_URL } from 'src/config/hub'
|
|||
import { useAuthStore } from 'src/stores/auth'
|
||||
import { initials as ini, shortAgent } from 'src/composables/useFormatters'
|
||||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: { type: String, required: true }, // pour le suivi : 'Dispatch Job' | 'Issue' …
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { Notify } from 'quasar'
|
|||
import { DEPARTMENTS, suggestDepartments } from 'src/config/departments'
|
||||
import { resolveSkills } from 'src/api/roster'
|
||||
import OccupancyBands from 'src/components/shared/OccupancyBands.vue'
|
||||
import { skillSym } from 'src/composables/useSkillIcons' // icônes de compétences (SOURCE UNIQUE, partagée avec Planif)
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
|
|
@ -27,6 +28,7 @@ const props = defineProps({
|
|||
address: { type: String, default: '' },
|
||||
lat: { type: Number, default: null },
|
||||
lng: { type: Number, default: null },
|
||||
connectionType: { type: String, default: '' }, // type de connexion du lieu (Fibre/Wireless/LTE…) → injecte « sans-fil » si sans-fil
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'book'])
|
||||
|
||||
|
|
@ -38,13 +40,25 @@ const resolved = ref(null) // { skills, primary, confidence, source, reason }
|
|||
const aiLoading = ref(false)
|
||||
const occ = ref(null) // dernier chargement des bandes → résumé du dénominateur
|
||||
|
||||
// Compétence(s) effective(s) : résolveur si présent, sinon skills du chip choisi.
|
||||
const skills = computed(() => {
|
||||
if (resolved.value && resolved.value.skills) return resolved.value.skills
|
||||
const d = DEPARTMENTS.find(x => x.category === dept.value)
|
||||
return d ? (d.skills || []) : []
|
||||
})
|
||||
// Le client est-il en technologie SANS-FIL (vs fibre) ? → toute intervention terrain exige aussi « sans-fil ».
|
||||
const wireless = computed(() => /wireless|\blte\b|sans.?fil|\bfwa\b|radio|antenne/i.test(String(props.connectionType || '')))
|
||||
|
||||
// Compétence(s) effective(s) — ÉDITABLE : dérivée du résolveur/chip (+ « sans-fil » si client sans-fil),
|
||||
// mais l'agent peut RETIRER une compétence ajoutée à tort (ex. « sans-fil » sur un souci non lié au radio).
|
||||
const skills = ref([])
|
||||
function deriveSkills () {
|
||||
const base = (resolved.value && resolved.value.skills)
|
||||
? [...resolved.value.skills]
|
||||
: (() => { const d = DEPARTMENTS.find(x => x.category === dept.value); return d ? [...(d.skills || [])] : [] })()
|
||||
// Injecté seulement s'il y a DÉJÀ une compétence terrain (une demande traitée à distance ne devient pas « sans-fil »).
|
||||
if (wireless.value && base.length && !base.some(s => /sans.?fil|wireless/i.test(s))) base.push('sans-fil')
|
||||
skills.value = base
|
||||
}
|
||||
// Re-dérive quand le motif/la résolution/le type de connexion change (réinitialise donc les retraits manuels — voulu).
|
||||
watch([resolved, dept, wireless], deriveSkills, { immediate: true })
|
||||
function removeSkill (s) { skills.value = skills.value.filter(x => x !== s) }
|
||||
const primarySkill = computed(() => skills.value[0] || '')
|
||||
const isWirelessSkill = (s) => /sans.?fil|wireless/i.test(String(s || ''))
|
||||
const durH = computed(() => { const d = DEPARTMENTS.find(x => x.category === dept.value); return d ? (d.dur || 1) : 1 })
|
||||
|
||||
// Résumé du DÉNOMINATEUR ajusté (agrégé depuis les bandes) : heures libres + nb de techs qualifiés.
|
||||
|
|
@ -137,7 +151,13 @@ function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
|||
<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>
|
||||
<q-chip v-for="s in skills" :key="s" dense square removable :icon="skillSym(s)"
|
||||
:color="isWirelessSkill(s) ? 'indigo-1' : 'teal-1'" :text-color="isWirelessSkill(s) ? 'indigo-9' : 'teal-9'" class="q-ml-xs"
|
||||
@remove="removeSkill(s)">
|
||||
{{ s }}
|
||||
<q-tooltip v-if="isWirelessSkill(s) && wireless">Ajoutée automatiquement (client sans-fil {{ connectionType }}) — ✕ pour retirer si non lié</q-tooltip>
|
||||
<q-tooltip v-else>✕ pour retirer cette compétence si non requise</q-tooltip>
|
||||
</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>
|
||||
|
|
@ -153,10 +173,10 @@ function dayShort (iso) { const d = new Date(iso + 'T12:00:00'); return DOW[d.ge
|
|||
<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 class="text-caption text-grey-7">{{ summary.techs }} tech{{ summary.techs > 1 ? 's' : '' }} qualifié{{ summary.techs > 1 ? 's' : '' }}{{ skills.length ? ' « ' + skills.join(' + ') + ' »' : '' }} · {{ 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" />
|
||||
<OccupancyBands :skill="skills.join(',')" :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>
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ const { can } = usePermissions()
|
|||
const { newDialogOpen, newDialogChannel, openComposeDraft, panelOpen } = useConversations()
|
||||
const { requestCreate } = useCreateSignal()
|
||||
const { open, contextActions } = useCommandPalette()
|
||||
const emit = defineEmits(['nl']) // commande en langage naturel → MainLayout ouvre le copilote STAFF
|
||||
|
||||
const inputEl = ref(null)
|
||||
const query = ref('')
|
||||
|
|
@ -117,11 +118,16 @@ const filtered = computed(() => {
|
|||
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
|
||||
// Escape hatch en langage naturel : « Demander à l'assistant » (dispatch/planif). Toujours proposé
|
||||
// quand on tape → une COMMANDE (« assigne le job X à Simon ») n'a aucun match d'action/page, donc cet
|
||||
// item devient l'unique résultat (surligné) et Entrée ouvre le copilote STAFF. Voir project_ops_nl_commands_training.
|
||||
if (q) out.push({ id: 'nl-command', group: 'Assistant', icon: 'auto_awesome', color: '#7c6cf6', label: `Demander à l’assistant : « ${q} »`, caption: 'Interpréter en langage naturel — dispatch, planification…', keywords: q, run: () => emit('nl', q) })
|
||||
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 }
|
||||
// 'Assistant' (langage naturel) en DERNIER pour ne pas voler la position aux vrais résultats.
|
||||
const GLOBAL_RANK = { 'Actions': 1, 'Clients / Équipe': 2, 'Aller à': 3, 'Assistant': 9 }
|
||||
// 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(() => {
|
||||
|
|
|
|||
|
|
@ -575,11 +575,13 @@
|
|||
<!-- En-tête repliable (accordéon, style Gmail) : qui · heure · aperçu — clic pour déplier/replier -->
|
||||
<div class="msg-head" :class="{ 'msg-head-open': isExpanded(msg) }" @click="toggleMsg(msg)">
|
||||
<div class="msg-head-line1 row items-center no-wrap">
|
||||
<!-- Avatar coloré + initiales par message (style Gmail/CRM) → on voit d'un coup QUI a écrit dans un fil -->
|
||||
<q-avatar v-if="(activeDiscussion.messages || []).length > 1" size="22px" class="q-mr-xs msg-av" :style="{ background: msgAvatarColor(msg), color: '#fff', fontSize: '9px', fontWeight: 700 }">{{ msgAvatarInitials(msg) }}</q-avatar>
|
||||
<!-- Avatar (photo sinon initiales) + nom par message → on voit QUI a écrit.
|
||||
Affiché dès qu'un fil a >1 message ET TOUJOURS pour un message d'agent (staff),
|
||||
même seul dans le fil (sinon on ne sait pas quel collègue l'a envoyé — ex. courriel transféré). -->
|
||||
<UserAvatar v-if="showMsgWho(msg)" :email="msg.agent || msg.fromEmail" :name="msgWho(msg)" :size="22" class="q-mr-xs msg-av" />
|
||||
<q-icon :name="msgIcon(msg)" :color="msg.from === 'agent' ? (msg.via === 'ai' ? 'deep-purple-4' : 'green-5') : 'teal-6'" size="13px" class="q-mr-xs" />
|
||||
<router-link v-if="msg.from === 'customer' && coordCustomer && msgWho(msg) && (activeDiscussion.messages || []).length > 1" :to="'/clients/' + encodeURIComponent(coordCustomer)" class="msg-who" style="color:#3730a3;text-decoration:underline;text-underline-offset:2px" @click.stop>{{ msgWho(msg) }}<q-tooltip class="bg-grey-9" style="font-size:11px">Ouvrir la fiche client (OPS 360) ↗</q-tooltip></router-link>
|
||||
<span v-else-if="msgWho(msg) && (activeDiscussion.messages || []).length > 1" class="msg-who">{{ msgWho(msg) }}</span>
|
||||
<router-link v-if="msg.from === 'customer' && coordCustomer && showMsgWho(msg)" :to="'/clients/' + encodeURIComponent(coordCustomer)" class="msg-who" style="color:#3730a3;text-decoration:underline;text-underline-offset:2px" @click.stop>{{ msgWho(msg) }}<q-tooltip class="bg-grey-9" style="font-size:11px">Ouvrir la fiche client (OPS 360) ↗</q-tooltip></router-link>
|
||||
<span v-else-if="showMsgWho(msg)" class="msg-who">{{ msgWho(msg) }}</span>
|
||||
<q-space />
|
||||
<!-- Actions du message SUR la ligne d'en-tête (expéditeur) : répondre + agrandir le courriel -->
|
||||
<q-btn v-if="msg.from !== 'system' && activeDiscussion.status === 'active'" flat dense round size="xs" icon="reply" color="green-5" class="q-mr-xs" @click.stop="openReply(msg)"><q-tooltip>Répondre en citant CE message</q-tooltip></q-btn>
|
||||
|
|
@ -1020,6 +1022,14 @@
|
|||
|
||||
<!-- Ajouter le fil courant à une tâche (réutilisable — conversation/ticket/projet) -->
|
||||
<AddToTaskDialog v-model="addTaskOpen" :source="activeTaskSrc || taskSource" />
|
||||
|
||||
<!-- Détail ticket NATIF (ex-lien ERPNext desk) : ouvre la fiche ticket dans OPS -->
|
||||
<DetailModal
|
||||
v-model:open="tkModalOpen" :loading="tkModalLoading" :doctype="tkModalDoctype"
|
||||
:doc-name="tkModalDocName" :title="tkModalTitle" :doc="tkModalDoc"
|
||||
:comments="tkModalComments" :comms="tkModalComms" :files="tkModalFiles"
|
||||
:doc-fields="tkModalDocFields" :dispatch-jobs="tkModalDispatchJobs"
|
||||
@navigate="(dt, name, t) => tkOpenModal(dt, name, t)" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
|
@ -1034,10 +1044,16 @@ import RecipientSelect from 'src/components/shared/RecipientSelect.vue'
|
|||
import { useSoftphone } from 'src/composables/useSoftphone'
|
||||
import { shortAgent, staffColor, staffInitials, noteAuthorName, relTime } from 'src/composables/useFormatters'
|
||||
import { recipientLabel, messageIdentity, channelMeta, titleCase, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay'
|
||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||
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 DetailModal from 'src/components/shared/DetailModal.vue'
|
||||
import { useDetailModal } from 'src/composables/useDetailModal'
|
||||
|
||||
// Détail ticket NATIF (remplace l'ancien lien ERPNext desk) — ERPNext = base de données.
|
||||
const { modalOpen: tkModalOpen, modalLoading: tkModalLoading, modalDoctype: tkModalDoctype, modalDocName: tkModalDocName, modalTitle: tkModalTitle, modalDoc: tkModalDoc, modalComments: tkModalComments, modalComms: tkModalComms, modalFiles: tkModalFiles, modalDocFields: tkModalDocFields, modalDispatchJobs: tkModalDispatchJobs, openModal: tkOpenModal } = useDetailModal()
|
||||
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison → compétence → disponibilité (dénominateur filtré)
|
||||
import { emailSrcdoc } from 'src/composables/useEmailRender'
|
||||
|
||||
|
|
@ -1108,7 +1124,6 @@ function endDrag () { _drag = null; window.removeEventListener('pointermove', on
|
|||
const coordOpen = ref(false) // chrome de coordination replié par défaut (épuration de la vue conversation)
|
||||
|
||||
// ── Créer un ticket (ERPNext Issue OUVERT) depuis la conversation — la conversation RESTE ──
|
||||
const ERP_BASE = 'https://erp.gigafibre.ca'
|
||||
const ticketCategories = ['Support', 'Facturation', 'Installation', 'Fibre', 'Télévision', 'Téléphonie', 'Commercial', 'Autre']
|
||||
const creatingTicket = ref(false)
|
||||
const ticketDialog = ref({ open: false, title: '', category: 'Support', priority: 'Medium' })
|
||||
|
|
@ -1640,9 +1655,25 @@ const presence = computed(() => {
|
|||
// ── Bouton « Répondre » (Gmail) : composeur masqué jusqu'au clic ; le clic SIGNALE la présence aux collègues (notifyTyping → anneau qui clignote chez eux) ──
|
||||
const replyOpen = ref(false)
|
||||
function collapseAllMsgs () { expandedMsgs.value = new Set() }
|
||||
// Nom d'expéditeur pour le contenu SORTANT (blocs cités / transférés qui partent dans le courriel).
|
||||
// RÈGLE : jamais le nom PERSO d'un collègue interne côté externe → identité de GROUPE (« Support TARGO »,
|
||||
// « Équipe TARGO »). Le nom réel du collègue reste visible dans l'UI (msgWho), pas dans l'email.
|
||||
function outgoingTeamName () {
|
||||
const pick = replySendAs.value || senders.value.default || ''
|
||||
const m = String(pick).match(/^\s*"?([^"<]+?)"?\s*</) // « "Nom" <addr> » → Nom
|
||||
const name = m ? m[1].trim() : ''
|
||||
if (name && name.toLowerCase() !== 'personal') return name
|
||||
const d = String(senders.value.default || '').match(/^\s*"?([^"<]+?)"?\s*</)
|
||||
return (d && d[1].trim()) || 'Équipe TARGO'
|
||||
}
|
||||
// Nom à AFFICHER dans un bloc sortant pour le message `m` : groupe si interne (agent), sinon le nom réel (client/externe).
|
||||
function outgoingSenderName (m, fallback) {
|
||||
if (m && m.from === 'agent') return outgoingTeamName()
|
||||
return (m && m.fromName) || fallback || (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client')
|
||||
}
|
||||
// Citation ÉDITABLE du message auquel on répond (en-tête + contenu), style Gmail — l'agent écrit au-dessus.
|
||||
function quotedBlock (m) {
|
||||
const who = m.fromName || (m.from === 'agent' ? 'Nous' : (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client'))
|
||||
const who = outgoingSenderName(m)
|
||||
const src = m.emailDate || m.ts // date d'ARRIVÉE du courriel (en-tête Date) en priorité, sinon l'horodatage d'ingestion
|
||||
const dt = src ? new Date(src) : null
|
||||
const when = (dt && !isNaN(dt.getTime())) ? formatDate(src) : '' // illisible → on OMET la date (plus de « Le Invalid Date, … »)
|
||||
|
|
@ -1693,7 +1724,8 @@ function escapeHtml (s) { return String(s == null ? '' : s).replace(/[&<>]/g, c
|
|||
// Bloc « Message transféré » (en-têtes De/Date/Objet/À + corps), style Gmail.
|
||||
function forwardedBlock (m) {
|
||||
if (!m) return ''
|
||||
const who = msgWho(m) || (m.from === 'agent' ? 'Nous' : (activeDiscussion.value?.customerName || activeDiscussion.value?.email || 'Client'))
|
||||
// « De : » sortant — identité de GROUPE pour un collègue interne (jamais le nom perso côté externe).
|
||||
const who = outgoingSenderName(m)
|
||||
const when = msgFullDate(m) || relTime(m.emailDate || m.ts)
|
||||
const subj = convSubject.value || activeDiscussion.value?.lastSubject || ''
|
||||
const to = msgTo(m)
|
||||
|
|
@ -1939,6 +1971,13 @@ function msgAvatarInitials (m) { return msgIdent(m).initials }
|
|||
function msgFullDate (m) { const d = new Date(m.emailDate || m.ts); return isNaN(d.getTime()) ? '' : d.toLocaleString('fr-CA', { dateStyle: 'full', timeStyle: 'short' }) }
|
||||
// Expéditeur d'UN message (nom) — délègue au resolver unique msgIdent (cohérent avec l'avatar ; jamais vide sauf système ; agents capitalisés).
|
||||
function msgWho (m) { return msgIdent(m).name }
|
||||
// Afficher le nom/avatar de l'expéditeur ? Oui si le fil a >1 message (contexte utile),
|
||||
// et TOUJOURS pour un message d'agent (staff) même seul — sinon on ignore quel collègue
|
||||
// l'a envoyé (ex. courriel transféré à un seul message). Jamais pour 'system'.
|
||||
function showMsgWho (m) {
|
||||
if (!m || m.from === 'system' || !msgWho(m)) return false
|
||||
return (activeDiscussion.value?.messages || []).length > 1 || m.from === 'agent'
|
||||
}
|
||||
function msgIcon (m) { return m.html ? 'mail' : m.via === 'sms' ? 'sms' : m.via === 'ai' ? 'smart_toy' : m.media ? 'image' : 'chat' }
|
||||
function msgSnippet (m) { const t = String(m.text || '').replace(/^✉️[^\n]*\n+/, '').replace(/\s+/g, ' ').trim(); return t.slice(0, 64) || (m.media ? '[image]' : m.html ? '[courriel]' : '') }
|
||||
function cleanBody (t) { return String(t || '').replace(/^✉️[^\n]*\n+/, '') } // retire l'ancien préfixe sujet collé au corps (double en-tête)
|
||||
|
|
@ -2042,7 +2081,7 @@ async function submitTicket () {
|
|||
$q.notify({ type: 'positive', icon: 'confirmation_number', message: `Ticket ${r.issue} créé`, timeout: 2800 })
|
||||
} catch (e) { $q.notify({ type: 'negative', message: e.message || 'Échec de création', timeout: 4000 }) } finally { creatingTicket.value = false }
|
||||
}
|
||||
function openTicket (name) { window.open(`${ERP_BASE}/app/issue/${encodeURIComponent(name)}`, '_blank') }
|
||||
function openTicket (name) { tkOpenModal('Issue', name, name) } // ouvre le détail ticket NATIF (DetailModal → IssueDetail)
|
||||
|
||||
// ── Inbox unifié : filtre (conversations + tickets) + helpers d'affichage des tickets ──
|
||||
const inboxFilter = ref('all') // 'all' | 'conv' | 'tickets'
|
||||
|
|
|
|||
|
|
@ -15,7 +15,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<slot name="header-actions" />
|
||||
<q-btn flat round dense icon="open_in_new" @click="openExternal(erpLinkUrl)" class="q-mr-xs" />
|
||||
<!-- ERPNext = base de données : on masque le lien desk dès qu'un module NATIF (detail-section) existe.
|
||||
Il ne reste que pour un doctype sans vue native (repli grille générique) — escape hatch temporaire. -->
|
||||
<q-btn v-if="!sectionComponent" flat round dense icon="open_in_new" @click="openExternal(erpLinkUrl)" class="q-mr-xs"><q-tooltip>Ouvrir dans ERPNext (base de données)</q-tooltip></q-btn>
|
||||
<q-btn flat round dense icon="close" @click="$emit('update:open', false)" />
|
||||
</q-card-section>
|
||||
|
||||
|
|
@ -29,7 +31,7 @@
|
|||
<component :is="sectionComponent" v-if="sectionComponent"
|
||||
:doc="doc" :doc-name="docName" :title="title"
|
||||
:comments="comments" :comms="comms" :files="files"
|
||||
:dispatch-jobs="dispatchJobs"
|
||||
:dispatch-jobs="dispatchJobs" :hide-customer-link="hideCustomerLink"
|
||||
@navigate="(...a) => $emit('navigate', ...a)"
|
||||
@reply-sent="(...a) => $emit('reply-sent', ...a)"
|
||||
@save-field="(...a) => $emit('save-field', ...a)"
|
||||
|
|
@ -93,6 +95,7 @@ const props = defineProps({
|
|||
files: { type: Array, default: () => [] },
|
||||
docFields: { type: Object, default: () => ({}) },
|
||||
dispatchJobs: { type: Array, default: () => [] },
|
||||
hideCustomerLink: { type: Boolean, default: false }, // relayé aux sections (ex. IssueDetail) : masque la bannière client quand on est déjà sur la fiche
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'navigate', 'open-pdf', 'save-field', 'toggle-recurring', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted', 'contract-terminated'])
|
||||
|
|
|
|||
155
apps/ops/src/components/shared/EmployeeEditDialog.vue
Normal file
155
apps/ops/src/components/shared/EmployeeEditDialog.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<template>
|
||||
<q-dialog v-model="open" @show="onShow">
|
||||
<q-card style="min-width:420px;max-width:520px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">{{ mode === 'link' ? 'Lier un profil employé' : 'Profil employé' }}</div>
|
||||
<q-space />
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section v-if="loading" class="column items-center q-py-lg">
|
||||
<q-spinner size="28px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Chargement…</div>
|
||||
</q-card-section>
|
||||
|
||||
<!-- MODE LIEN : aucun employé lié à ce compte → choisir un employé existant -->
|
||||
<q-card-section v-else-if="mode === 'link'">
|
||||
<div class="text-caption text-grey-7 q-mb-sm">
|
||||
Aucun profil employé lié à <b>{{ linkEmail }}</b>. Choisissez l'employé correspondant :
|
||||
on renseignera son <code>user_id</code> pour établir le lien.
|
||||
</div>
|
||||
<q-select v-model="linkTarget" :options="empOptions" use-input input-debounce="300"
|
||||
outlined dense label="Rechercher un employé…" option-label="employee_name" option-value="name"
|
||||
@filter="filterEmployees" :loading="empSearching" clearable>
|
||||
<template #option="scope">
|
||||
<q-item v-bind="scope.itemProps">
|
||||
<q-item-section><q-item-label>{{ scope.opt.employee_name }}</q-item-label>
|
||||
<q-item-label caption>{{ scope.opt.name }}<span v-if="scope.opt.user_id"> · déjà lié à {{ scope.opt.user_id }}</span></q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</template>
|
||||
</q-select>
|
||||
</q-card-section>
|
||||
|
||||
<!-- MODE ÉDITION : champs de l'employé + lien de connexion -->
|
||||
<q-card-section v-else class="q-gutter-sm">
|
||||
<q-input v-model="form.employee_name" label="Nom (RH)" outlined dense :disable="saving" />
|
||||
<q-select v-model="form.designation" :options="designationOpts" label="Poste" outlined dense
|
||||
use-input input-debounce="0" @filter="filterDesignations" clearable emit-value map-options :disable="saving" />
|
||||
<q-select v-model="form.department" :options="departmentOpts" label="Département" outlined dense
|
||||
use-input input-debounce="0" @filter="filterDepartments" clearable emit-value map-options :disable="saving" />
|
||||
<div class="row q-col-gutter-sm">
|
||||
<q-input class="col" v-model="form.cell_number" label="Téléphone" outlined dense :disable="saving" />
|
||||
<q-input class="col-4" v-model="form.office_extension" label="Ext." outlined dense :disable="saving" />
|
||||
</div>
|
||||
<q-input v-model="form.company_email" label="Courriel (entreprise)" outlined dense :disable="saving" />
|
||||
<q-input v-model="form.user_id" label="Compte de connexion lié (user_id)" outlined dense :disable="saving"
|
||||
hint="Courriel du compte SSO/ERPNext — établit le lien session ↔ employé">
|
||||
<template #prepend><q-icon name="link" /></template>
|
||||
</q-input>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right" class="q-pb-md q-pr-md" v-if="!loading">
|
||||
<q-btn flat label="Annuler" v-close-popup :disable="saving" />
|
||||
<q-btn v-if="mode === 'link'" unelevated color="primary" label="Lier" icon="link"
|
||||
:loading="saving" :disable="!linkTarget" @click="doLink" />
|
||||
<q-btn v-else unelevated color="primary" label="Enregistrer" icon="save" :loading="saving" @click="save" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { listDocs, getDoc, updateDoc } from 'src/api/erp'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
// Éditer un employé précis (nom du doc ERPNext). Prioritaire sur linkEmail.
|
||||
employeeName: { type: String, default: '' },
|
||||
// Lier : courriel du compte sans employé (mode recherche/sélection).
|
||||
linkEmail: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||
const open = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const mode = computed(() => props.employeeName ? 'edit' : 'link')
|
||||
|
||||
const EDITABLE = ['employee_name', 'designation', 'department', 'cell_number', 'office_extension', 'company_email', 'user_id']
|
||||
const form = reactive({ employee_name: '', designation: null, department: null, cell_number: '', office_extension: '', company_email: '', user_id: '' })
|
||||
const original = reactive({})
|
||||
|
||||
// Options Link (Designation/Department) chargées à l'ouverture, filtrables localement.
|
||||
const allDesignations = ref([]), allDepartments = ref([])
|
||||
const designationOpts = ref([]), departmentOpts = ref([])
|
||||
function filterDesignations (val, update) { update(() => { const n = (val || '').toLowerCase(); designationOpts.value = allDesignations.value.filter(o => o.label.toLowerCase().includes(n)) }) }
|
||||
function filterDepartments (val, update) { update(() => { const n = (val || '').toLowerCase(); departmentOpts.value = allDepartments.value.filter(o => o.label.toLowerCase().includes(n)) }) }
|
||||
|
||||
// Mode lien : recherche d'employés.
|
||||
const linkTarget = ref(null)
|
||||
const empOptions = ref([])
|
||||
const empSearching = ref(false)
|
||||
function filterEmployees (val, update) {
|
||||
const q = (val || '').trim()
|
||||
update(async () => {
|
||||
empSearching.value = true
|
||||
try {
|
||||
empOptions.value = await listDocs('Employee', {
|
||||
filters: { status: 'Active' },
|
||||
or_filters: q ? [['employee_name', 'like', '%' + q + '%'], ['name', 'like', '%' + q + '%']] : undefined,
|
||||
fields: ['name', 'employee_name', 'user_id'], limit: 15, orderBy: 'employee_name asc',
|
||||
})
|
||||
} catch { empOptions.value = [] } finally { empSearching.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
async function onShow () {
|
||||
linkTarget.value = null
|
||||
if (mode.value === 'edit') {
|
||||
loading.value = true
|
||||
try {
|
||||
const [doc, desigs, depts] = await Promise.all([
|
||||
getDoc('Employee', props.employeeName),
|
||||
listDocs('Designation', { fields: ['name'], limit: 200, orderBy: 'name asc' }),
|
||||
listDocs('Department', { fields: ['name'], limit: 200, orderBy: 'name asc' }),
|
||||
])
|
||||
allDesignations.value = desigs.map(d => ({ label: d.name, value: d.name }))
|
||||
allDepartments.value = depts.map(d => ({ label: d.name, value: d.name }))
|
||||
designationOpts.value = allDesignations.value; departmentOpts.value = allDepartments.value
|
||||
for (const k of EDITABLE) { form[k] = doc[k] ?? (k === 'designation' || k === 'department' ? null : ''); original[k] = form[k] }
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Chargement employé échoué: ' + e.message })
|
||||
open.value = false
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
}
|
||||
|
||||
async function save () {
|
||||
const changed = {}
|
||||
for (const k of EDITABLE) if ((form[k] ?? '') !== (original[k] ?? '')) changed[k] = form[k] || ''
|
||||
if (!Object.keys(changed).length) { open.value = false; return }
|
||||
saving.value = true
|
||||
try {
|
||||
await updateDoc('Employee', props.employeeName, changed)
|
||||
Notify.create({ type: 'positive', message: 'Profil employé mis à jour', timeout: 1500 })
|
||||
emit('saved', { name: props.employeeName, ...changed })
|
||||
open.value = false
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Sauvegarde échouée: ' + e.message })
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function doLink () {
|
||||
if (!linkTarget.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await updateDoc('Employee', linkTarget.value.name, { user_id: props.linkEmail })
|
||||
Notify.create({ type: 'positive', message: `${linkTarget.value.employee_name} lié à ${props.linkEmail}`, timeout: 2000 })
|
||||
emit('saved', { name: linkTarget.value.name, user_id: props.linkEmail, linked: true })
|
||||
open.value = false
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Liaison échouée: ' + e.message })
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
63
apps/ops/src/components/shared/HelpHint.vue
Normal file
63
apps/ops/src/components/shared/HelpHint.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<template>
|
||||
<!-- Bulle d'aide contextuelle : petit ⓘ qui explique une fonction / une action.
|
||||
Utilise q-tooltip → régi par le boot app-wide tooltip-ux (clic = masque, jamais 2 bulles). -->
|
||||
<q-icon
|
||||
:name="icon"
|
||||
:size="size"
|
||||
class="help-hint"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
:aria-label="aria || 'Aide'"
|
||||
>
|
||||
<q-tooltip
|
||||
class="help-hint-tt"
|
||||
:max-width="maxWidth"
|
||||
anchor="top middle"
|
||||
self="bottom middle"
|
||||
:offset="[0, 6]"
|
||||
:delay="150"
|
||||
>
|
||||
<div v-if="title" class="help-hint-title">{{ title }}</div>
|
||||
<div class="help-hint-body"><slot>{{ text }}</slot></div>
|
||||
</q-tooltip>
|
||||
</q-icon>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
// Texte simple ; sinon passer du contenu riche via le slot par défaut.
|
||||
text: { type: String, default: '' },
|
||||
// Titre optionnel en gras au-dessus du texte.
|
||||
title: { type: String, default: '' },
|
||||
icon: { type: String, default: 'info_outline' },
|
||||
size: { type: String, default: '15px' },
|
||||
maxWidth: { type: String, default: '280px' },
|
||||
aria: { type: String, default: '' },
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.help-hint {
|
||||
color: var(--ops-muted, #94a3b8);
|
||||
cursor: help;
|
||||
vertical-align: middle;
|
||||
transition: color 0.12s ease;
|
||||
}
|
||||
.help-hint:hover,
|
||||
.help-hint:focus-visible {
|
||||
color: var(--ops-accent, #0c8f9e);
|
||||
outline: none;
|
||||
}
|
||||
.help-hint-tt {
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
padding: 8px 11px;
|
||||
}
|
||||
.help-hint-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
.help-hint-body :deep(b) { font-weight: 700; }
|
||||
.help-hint-body :deep(ul) { margin: 4px 0 0; padding-left: 16px; }
|
||||
.help-hint-body :deep(li) { margin: 2px 0; }
|
||||
</style>
|
||||
|
|
@ -75,9 +75,9 @@ defineExpose({ reload: load })
|
|||
<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 }"
|
||||
<div v-for="(b, bi) in c.blocks" :key="bi" class="occ-block" :class="{ reserved: b.reserved, untimed: b.untimed }"
|
||||
: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>
|
||||
<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' : '' }}<span v-if="c.untimed_jobs"><br>⚠ {{ c.untimed_jobs }} sans heure fixée</span></q-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -108,6 +108,8 @@ defineExpose({ reload: load })
|
|||
.occ-strip.off { background: repeating-linear-gradient(45deg, #f1f5f9, #f1f5f9 4px, #e2e8f0 4px, #e2e8f0 8px); border-color: #e2e8f0; cursor: default; }
|
||||
.occ-block { position: absolute; left: 1px; right: 1px; background: #2563eb; border-radius: 2px; }
|
||||
.occ-block.reserved { background: #f59e0b; }
|
||||
/* Jobs assignés SANS heure fixée (legacy) : occupent la journée mais heure non planifiée → hachuré. */
|
||||
.occ-block.untimed { background: repeating-linear-gradient(45deg, #2563eb, #2563eb 3px, #60a5fa 3px, #60a5fa 6px); opacity: .85; }
|
||||
.occ-legend { font-size: 10.5px; color: #475569; }
|
||||
.occ-dot { display: inline-block; width: 9px; height: 9px; border-radius: 2px; vertical-align: middle; margin-right: 3px; }
|
||||
.occ-dot.job { background: #2563eb; }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<q-card style="min-width:540px;max-width:96vw">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<q-icon name="bolt" color="deep-purple-6" size="22px" class="q-mr-sm" />
|
||||
<div class="text-subtitle1 text-weight-bold">Commande dictée</div>
|
||||
<div class="text-subtitle1 text-weight-bold">{{ title }}</div>
|
||||
<q-space />
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
|
|
@ -22,6 +22,9 @@
|
|||
<q-btn unelevated color="deep-purple-6" icon="auto_awesome" label="Préparer le plan" no-caps :disable="!text.trim() || planning" :loading="planning" @click="plan" />
|
||||
</div>
|
||||
|
||||
<!-- Réponse de l'assistant (résumé NL / résultat des lectures, ex. « Pour quel technicien ? ») -->
|
||||
<div v-if="reply" class="orch-reply q-mt-md">{{ reply }}</div>
|
||||
|
||||
<!-- Vérifications (lecture immédiate — pas de confirmation) -->
|
||||
<div v-if="diagnoses.length" class="q-mt-md">
|
||||
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
|
||||
|
|
@ -72,6 +75,9 @@
|
|||
<q-space />
|
||||
<q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" />
|
||||
</div>
|
||||
<!-- Aperçu générique (outils STAFF dispatch/planification : create_job, assign_tech, create_recurring_shift…) -->
|
||||
<div v-if="a.preview" class="text-body2 text-grey-8 q-mb-xs">{{ a.preview }}</div>
|
||||
<q-badge v-if="a.severity === 'high'" color="red-1" text-color="red-9" class="q-mb-xs"><q-icon name="warning" size="13px" class="q-mr-xs" />Action conséquente</q-badge>
|
||||
<!-- destinataire / client résolu -->
|
||||
<div v-if="a.candidates && a.candidates.length" class="q-mb-xs">
|
||||
<q-select dense outlined v-model="a.resolved" :options="a.candidates" option-label="customer_name" map-options emit-value
|
||||
|
|
@ -122,11 +128,22 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ref, computed, watch, nextTick } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
import { useAuthStore } from 'src/stores/auth'
|
||||
|
||||
const props = defineProps({ modelValue: Boolean })
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
// Réutilisable pour DEUX surfaces NL (cf feedback_reuse_shared_components) : l'orchestrateur COMMS
|
||||
// (défauts /collab/orchestrate — tickets/SMS/courriel) ET le copilote STAFF dispatch/planif
|
||||
// (plan-endpoint="/staff-agent"). Défauts = comportement comms inchangé.
|
||||
initialText: { type: String, default: '' }, // pré-remplissage (ex. requête ⌘K) → auto-plan à l'ouverture
|
||||
title: { type: String, default: 'Commande dictée' },
|
||||
planEndpoint: { type: String, default: '/collab/orchestrate' },
|
||||
runEndpoint: { type: String, default: '/collab/orchestrate/run' },
|
||||
sendIdentity: { type: Boolean, default: false }, // joindre X-Authentik-Email (routes STAFF : agissent au nom de l'agent connecté)
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'done'])
|
||||
const $q = useQuasar()
|
||||
|
||||
|
|
@ -134,6 +151,14 @@ const open = ref(false)
|
|||
const text = ref('')
|
||||
const actions = ref([])
|
||||
const results = ref([])
|
||||
const reply = ref('')
|
||||
|
||||
// En-têtes de requête : identité de l'agent connecté pour les routes STAFF (attribution + suivi + permissions).
|
||||
function reqHeaders () {
|
||||
const h = { 'Content-Type': 'application/json' }
|
||||
if (props.sendIdentity) { const u = useAuthStore().user; if (u && u !== 'authenticated') h['X-Authentik-Email'] = u }
|
||||
return h
|
||||
}
|
||||
const planning = ref(false)
|
||||
const running = ref(false)
|
||||
const listening = ref(false)
|
||||
|
|
@ -149,19 +174,20 @@ async function pickDiag (a, m) {
|
|||
try { const r = await fetch(`${HUB_URL}/collab/service-status?customer=${encodeURIComponent(m.name)}`); if (r.ok) a.diagnostic = await r.json() } catch (e) { a.diagnostic = { ok: false, error: e.message } }
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, v => { open.value = v; if (v) reset() })
|
||||
watch(() => props.modelValue, v => { open.value = v; if (v) { reset(); if (props.initialText) { text.value = props.initialText; nextTick(() => plan()) } } })
|
||||
watch(open, v => emit('update:modelValue', v))
|
||||
function reset () { text.value = ''; actions.value = []; results.value = []; if (recog) { try { recog.stop() } catch (e) {} } listening.value = false }
|
||||
function reset () { text.value = ''; actions.value = []; results.value = []; reply.value = ''; if (recog) { try { recog.stop() } catch (e) {} } listening.value = false }
|
||||
|
||||
const iconOf = t => ({ create_ticket: 'confirmation_number', send_sms: 'sms', send_email: 'mail', note: 'sticky_note_2' }[t] || 'bolt')
|
||||
const colorOf = t => ({ create_ticket: 'teal-7', send_sms: 'green-6', send_email: 'red-5', note: 'amber-7' }[t] || 'grey-7')
|
||||
const labelOf = t => ({ create_ticket: 'Créer un ticket', send_sms: 'Envoyer un texto', send_email: 'Envoyer un courriel', note: 'Note interne' }[t] || t)
|
||||
const iconOf = t => ({ create_ticket: 'confirmation_number', send_sms: 'sms', send_email: 'mail', note: 'sticky_note_2', create_job: 'add_task', assign_tech: 'engineering', add_assistant: 'group_add', set_job_status: 'flag', reschedule_job: 'event_repeat', create_recurring_shift: 'event_available', follow_doc: 'notifications_active' }[t] || 'bolt')
|
||||
const colorOf = t => ({ create_ticket: 'teal-7', send_sms: 'green-6', send_email: 'red-5', note: 'amber-7', create_job: 'indigo-6', assign_tech: 'indigo-6', add_assistant: 'blue-grey-6', set_job_status: 'deep-orange-6', reschedule_job: 'deep-purple-5', create_recurring_shift: 'teal-6', follow_doc: 'blue-6' }[t] || 'grey-7')
|
||||
const labelOf = t => ({ create_ticket: 'Créer un ticket', send_sms: 'Envoyer un texto', send_email: 'Envoyer un courriel', note: 'Note interne', create_job: 'Créer un job', assign_tech: 'Assigner un technicien', add_assistant: 'Ajouter un assistant', set_job_status: 'Changer le statut', reschedule_job: 'Reporter le rendez-vous', create_recurring_shift: 'Horaire récurrent', follow_doc: 'Suivre' }[t] || t)
|
||||
function summaryOf (r) {
|
||||
if (r.type === 'create_ticket') return r.ok ? `Ticket ${r.name} créé (${r.label || ''})` : 'Ticket : ' + (r.error || 'échec')
|
||||
if (r.type === 'send_sms') return r.ok ? `Texto envoyé à ${r.to} (${r.via})` : 'Texto : ' + (r.error || 'échec')
|
||||
if (r.type === 'send_email') return r.ok ? `Courriel envoyé à ${r.to}` : 'Courriel : ' + (r.error || 'échec')
|
||||
if (r.type === 'note') return 'Note : ' + (r.text || '')
|
||||
return JSON.stringify(r)
|
||||
// Actions STAFF dispatch/planif (et repli générique) : l'exécuteur renvoie { message } / { error }.
|
||||
return (labelOf(r.type) || 'Action') + ' : ' + (r.message || r.error || (r.ok ? 'fait' : 'échec'))
|
||||
}
|
||||
|
||||
function toggleMic () {
|
||||
|
|
@ -179,12 +205,13 @@ async function plan () {
|
|||
if (!text.value.trim()) return
|
||||
planning.value = true; results.value = []
|
||||
try {
|
||||
const r = await fetch(`${HUB_URL}/collab/orchestrate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: text.value.trim() }) })
|
||||
const r = await fetch(`${HUB_URL}${props.planEndpoint}`, { method: 'POST', headers: reqHeaders(), body: JSON.stringify({ text: text.value.trim() }) })
|
||||
const d = await r.json()
|
||||
if (d.ok) {
|
||||
actions.value = d.actions || []
|
||||
reply.value = d.reply || '' // le copilote STAFF renvoie un résumé NL / une question ; l'orchestrateur comms non
|
||||
for (const a of actions.value) if (a.type === 'create_ticket' && a.resolved) onResolved(a) // pré-charge les adresses de service
|
||||
if (!actions.value.length) $q.notify({ type: 'info', message: 'Aucune action détectée — reformule.' })
|
||||
if (!actions.value.length && !reply.value) $q.notify({ type: 'info', message: 'Aucune action détectée — reformule.' })
|
||||
} else $q.notify({ type: 'negative', message: d.error || 'Échec' })
|
||||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { planning.value = false }
|
||||
}
|
||||
|
|
@ -201,7 +228,7 @@ function locLabel (a) { const l = (a._locs || []).find(x => x.name === a.service
|
|||
async function run () {
|
||||
running.value = true
|
||||
try {
|
||||
const r = await fetch(`${HUB_URL}/collab/orchestrate/run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ actions: writeActions.value }) })
|
||||
const r = await fetch(`${HUB_URL}${props.runEndpoint}`, { method: 'POST', headers: reqHeaders(), body: JSON.stringify({ actions: writeActions.value }) })
|
||||
const d = await r.json()
|
||||
results.value = d.results || []
|
||||
const ok = results.value.filter(x => x.ok).length
|
||||
|
|
@ -215,4 +242,5 @@ async function run () {
|
|||
.orch-act { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #faf9ff; }
|
||||
.orch-diag { border: 1px solid #e0e7ff; border-radius: 8px; padding: 10px; margin-bottom: 8px; background: #f5f7ff; }
|
||||
.diag-dot { width: 11px; height: 11px; border-radius: 50%; margin-right: 10px; flex: 0 0 auto; }
|
||||
.orch-reply { border-left: 3px solid #7c6cf6; background: #f6f5ff; padding: 8px 12px; border-radius: 0 8px 8px 0; color: #4a4a5a; font-size: 0.92rem; white-space: pre-wrap; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
<template>
|
||||
<div v-if="others.length" class="rstack">
|
||||
<q-avatar v-for="r in head" :key="r.email" :size="size + 'px'" class="rstack-av"
|
||||
:style="{ background: staffColor(r.email), color: '#fff', fontSize: Math.round(size * 0.42) + 'px' }">
|
||||
{{ staffInitials(noteAuthorName(r.email)) }}
|
||||
<UserAvatar v-for="r in head" :key="r.email" :email="r.email" :name="noteAuthorName(r.email)"
|
||||
:size="size" class="rstack-av">
|
||||
<q-tooltip>{{ shortAgent(r.email) }}<template v-if="r.readAt"> · {{ relTime(r.readAt) }}</template></q-tooltip>
|
||||
</q-avatar>
|
||||
</UserAvatar>
|
||||
<span v-if="extra > 0" class="rstack-more">+{{ extra }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { staffColor, staffInitials, noteAuthorName, shortAgent, relTime } from 'src/composables/useFormatters'
|
||||
import { noteAuthorName, shortAgent, relTime } from 'src/composables/useFormatters'
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
|
||||
const props = defineProps({
|
||||
readers: { type: Array, default: () => [] }, // [{ email, readAt }]
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const props = defineProps({
|
|||
routes: { type: Array, default: () => [] },
|
||||
live: { type: Array, default: () => [] }, // positions GPS LIVE : [{ techId, name|techName, color, lat, lon, time, speed(nœuds) }]
|
||||
pins: { type: Array, default: () => [] }, // jobs NON assignés à situer : [{ lat, lon, subject, name, color(priorité), city }]
|
||||
track: { type: Object, default: null }, // tracé GPS RÉEL (Traccar) d'UN tech sur la journée : { coords:[[lon,lat]…] } — couche orange au 1er plan (vs tournées prévues)
|
||||
height: { type: String, default: '440px' },
|
||||
})
|
||||
const emit = defineEmits(['metrics', 'stop-click', 'pin-click'])
|
||||
|
|
@ -66,6 +67,12 @@ function clearMarkers () { for (const m of stopMk) m.mk.remove(); for (const m o
|
|||
// ── Positions GPS LIVE des techs (marqueur distinct des arrêts : pastille ronde à initiales + halo pulsé) ──
|
||||
// initials → composables/useFormatters (source unique)
|
||||
function clearLive () { for (const m of liveMk) m.mk.remove(); liveMk = [] }
|
||||
function renderTrack (tk) { // tracé GPS réel (couche GL orange) — vidé si absent
|
||||
if (!map || !ready) return
|
||||
const src = map.getSource('rm-track'); if (!src) return
|
||||
const coords = (tk && Array.isArray(tk.coords)) ? tk.coords : []
|
||||
src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
function clearPins () { for (const m of pinMk) m.mk.remove(); pinMk = [] }
|
||||
function renderLive (list) {
|
||||
if (!map || !ready) return
|
||||
|
|
@ -77,14 +84,23 @@ function renderLive (list) {
|
|||
const stale = ageMin != null && ageMin > 10 // > 10 min sans fix = position vieillie (pas de halo, grisée)
|
||||
const color = p.color || '#1e88e5'
|
||||
const wrap = document.createElement('div'); wrap.className = 'rm-live' + (stale ? ' stale' : '')
|
||||
wrap.innerHTML = '<span class="rm-live-pulse" style="background:' + color + '"></span>' +
|
||||
// Élément INTERNE fannable : Mapbox pose sa transform de positionnement sur `wrap` (racine du marqueur) → on translate `inner`,
|
||||
// pas `wrap` (comme les arrêts translatent .rm-pill, enfant de .rm-mk), sinon on casserait le placement GPS.
|
||||
const inner = document.createElement('div'); inner.className = 'rm-live-in'
|
||||
inner.innerHTML = '<span class="rm-live-pulse" style="background:' + color + '"></span>' +
|
||||
'<span class="rm-live-dot" style="background:' + color + '">' + initials(p.name || p.techName) + '</span>'
|
||||
wrap.appendChild(inner)
|
||||
const spd = (p.speed != null && p.speed > 1) ? Math.round(p.speed * 1.852) + ' km/h' : 'arrêté' // Traccar speed = nœuds → km/h
|
||||
const when = ageMin == null ? 'position' : (ageMin <= 1 ? "à l'instant" : 'il y a ' + ageMin + ' min')
|
||||
wrap.title = (p.name || p.techName || '') + ' — ' + when + ' · ' + spd
|
||||
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+p.lon, +p.lat]).addTo(map)
|
||||
liveMk.push({ mk, el: wrap })
|
||||
// Rejoint l'ÉCLATEMENT en éventail : quand une position GPS chevauche des arrêts/gouttes, le groupe se sépare au survol.
|
||||
const rec = { mk, el: wrap, disc: inner, rid: p.techId, lngLat: [+p.lon, +p.lat], fan: null, members: null, live: true }
|
||||
wrap.addEventListener('mouseenter', () => onEnter(rec))
|
||||
wrap.addEventListener('mouseleave', () => onLeave(rec))
|
||||
liveMk.push(rec)
|
||||
}
|
||||
recluster()
|
||||
}
|
||||
function esc (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])) }
|
||||
// Jobs NON assignés du jour, GROUPÉS PAR ADRESSE : pastille round-rect avec les icônes des types de jobs + le temps
|
||||
|
|
@ -132,7 +148,11 @@ function renderMarkers (routes) {
|
|||
const d = document.createElement('div'); d.className = 'rm-home'; d.style.borderColor = r.color; wrap.appendChild(d)
|
||||
const mk = new mapboxgl.Marker({ element: wrap, anchor: 'center' }).setLngLat([+r.home.lon, +r.home.lat]).addTo(map)
|
||||
wrap.title = r.name + ' — départ'
|
||||
homeMk.push({ mk, el: wrap, rid: r.id })
|
||||
// Le point de DÉPART (domicile/dépôt) rejoint aussi l'éclatement : disc = pastille interne (mapbox transforme `wrap`) + survol → fan.
|
||||
const rec = { mk, el: wrap, disc: d, rid: r.id, lngLat: [+r.home.lon, +r.home.lat], fan: null, members: null, home: true }
|
||||
wrap.addEventListener('mouseenter', () => onEnter(rec))
|
||||
wrap.addEventListener('mouseleave', () => onLeave(rec))
|
||||
homeMk.push(rec)
|
||||
}
|
||||
for (const s of (r.stops || [])) {
|
||||
// Pastille = ROUND-RECT (pill) : icône du type de job + numéro d'ordre (best practice quand 2 infos ; cf. outils de tournée).
|
||||
|
|
@ -153,8 +173,8 @@ function renderMarkers (routes) {
|
|||
}
|
||||
// Regroupe les pastilles qui se CHEVAUCHENT à l'écran (distance pixel) → calcule pour chacune un décalage en éventail.
|
||||
function recluster () {
|
||||
// Arrêts assignés ET gouttes non assignées (par adresse) partagent l'éclatement en éventail au chevauchement.
|
||||
const all = stopMk.concat(pinMk)
|
||||
// TOUS les types d'icônes partagent l'éclatement au chevauchement : arrêts, gouttes (non assignés), positions GPS live ET départs (domicile/dépôt).
|
||||
const all = stopMk.concat(pinMk, liveMk, homeMk)
|
||||
if (!map || !all.length) return
|
||||
const px = all.map(s => map.project(s.lngLat))
|
||||
const parent = all.map((_, i) => i)
|
||||
|
|
@ -208,6 +228,7 @@ async function draw () {
|
|||
renderMarkers(routes)
|
||||
renderLive(props.live)
|
||||
renderPins(props.pins)
|
||||
renderTrack(props.track)
|
||||
const sig = routes.map(r => r.id + ':' + (r.stops || []).length).join('|') + '#' + (props.pins || []).length
|
||||
if (sig !== _lastSig) { _lastSig = sig; try { const b = boundsOf(routes); for (const p of (props.pins || [])) if (okLL(p.lon, p.lat)) b.extend([+p.lon, +p.lat]); if (!b.isEmpty()) map.fitBounds(b, { padding: 60, maxZoom: 13, duration: 400 }) } catch (e) {} }
|
||||
// Routes RÉELLES en asynchrone : remplace les segments droits + émet km/min réels. Ignoré si un draw plus récent est parti.
|
||||
|
|
@ -250,6 +271,10 @@ onMounted(async () => {
|
|||
map.addSource('rm-line', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({ id: 'rm-line', type: 'line', source: 'rm-line', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 3, 'line-opacity': 0.7 } })
|
||||
map.addLayer({ id: 'rm-line-a', type: 'line', source: 'rm-line', filter: ['==', ['get', 'rid'], '___aucun___'], layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'], 'line-width': 5.5, 'line-opacity': 0.95 } })
|
||||
// Tracé GPS RÉEL (Traccar) d'un seul tech — orange, AU-DESSUS des tournées prévues (halo + trait) → « où il est réellement passé ».
|
||||
map.addSource('rm-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
map.addLayer({ id: 'rm-track-halo', type: 'line', source: 'rm-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 9, 'line-opacity': 0.22 } })
|
||||
map.addLayer({ id: 'rm-track-l', type: 'line', source: 'rm-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3.5, 'line-opacity': 0.85 } })
|
||||
map.on('mousemove', 'rm-line', (e) => { const f = e.features && e.features[0]; if (f) setActive(f.properties.rid) })
|
||||
map.on('mouseleave', 'rm-line', () => setActive(null))
|
||||
map.on('movestart', () => { if (openMembers) { fan(openMembers, false); openMembers = null } }) // décalages px périmés au déplacement
|
||||
|
|
@ -260,8 +285,9 @@ onMounted(async () => {
|
|||
})
|
||||
onBeforeUnmount(() => { clearMarkers(); clearLive(); clearPins(); if (ro) { try { ro.disconnect() } catch (e) {} ro = null } if (map) { try { map.remove() } catch (e) {} map = null } ready = false })
|
||||
watch(() => props.routes, () => { draw() }, { deep: true })
|
||||
watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live sans redessiner les tournées
|
||||
watch(() => props.live, () => { renderLive(props.live) }, { deep: true }) // rafraîchit les positions live (+ recluster interne) sans redessiner les tournées
|
||||
watch(() => props.pins, () => { renderPins(props.pins) }, { deep: true }) // jobs non assignés (secteur) sans redessiner les tournées
|
||||
watch(() => props.track, () => { renderTrack(props.track) }, { deep: true }) // tracé GPS réel (1 tech) sans redessiner les tournées
|
||||
// Satellite : bouton local + préférence PARTAGÉE (satellitePref) → toutes les cartes suivent.
|
||||
function toggleSat () { setSatellitePref(!satellitePref.value); if (map) setSatelliteVisible(map, satellitePref.value) }
|
||||
watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
|
||||
|
|
@ -299,7 +325,7 @@ watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
|
|||
.rm-pop-row:hover { background: #eef2ff; }
|
||||
.rm-pop-s { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rm-pop-est { color: #64748b; font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||
.rm-mk .rm-home { width: 15px; height: 15px; border-radius: 50%; box-sizing: border-box; background: #fff; border: 3px solid #888; box-shadow: 0 1px 3px rgba(0,0,0,.4); }
|
||||
.rm-mk .rm-home { width: 15px; height: 15px; border-radius: 50%; box-sizing: border-box; background: #fff; border: 3px solid #888; box-shadow: 0 1px 3px rgba(0,0,0,.4); transition: transform .18s cubic-bezier(.2,.8,.3,1); }
|
||||
.rm-mk.hot { z-index: 7; }
|
||||
.rm-mk.hot .rm-pill { transform: scale(1.15); box-shadow: 0 3px 9px rgba(0,0,0,.55); }
|
||||
.rm-mk.route-hot { z-index: 5; }
|
||||
|
|
@ -307,6 +333,8 @@ watch(satellitePref, (v) => { if (map) setSatelliteVisible(map, v) })
|
|||
.rm-mk.fanned .rm-pill { box-shadow: 0 3px 10px rgba(0,0,0,.55); }
|
||||
/* Position GPS LIVE d'un tech : pastille ronde à initiales + halo pulsé (distincte des pastilles d'arrêt). */
|
||||
.rm-live { position: relative; width: 0; height: 0; z-index: 8; pointer-events: auto; }
|
||||
.rm-live.fanned { z-index: 9; } /* éclatée au chevauchement → passe devant arrêts/gouttes */
|
||||
.rm-live-in { position: absolute; width: 0; height: 0; transition: transform .18s cubic-bezier(.2,.8,.3,1); } /* élément translaté par l'éventail */
|
||||
.rm-live .rm-live-dot { position: absolute; transform: translate(-50%, -50%); width: 28px; height: 28px; border-radius: 50%; border: 2.5px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.5); color: #fff; font-size: 10.5px; font-weight: 800; display: flex; align-items: center; justify-content: center; z-index: 2; cursor: default; }
|
||||
.rm-live .rm-live-pulse { position: absolute; width: 28px; height: 28px; border-radius: 50%; opacity: .45; z-index: 1; animation: rm-live-pulse 1.8s ease-out infinite; }
|
||||
@keyframes rm-live-pulse { 0% { transform: translate(-50%,-50%) scale(1); opacity: .45; } 100% { transform: translate(-50%,-50%) scale(2.8); opacity: 0; } }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
Doctype-conscient : Issue (support) ou Dispatch Job (terrain). -->
|
||||
<q-btn-dropdown :label="label" dense no-caps unelevated :color="color" text-color="white" icon="schedule" :loading="busy">
|
||||
<q-list dense style="min-width:250px">
|
||||
<q-item-label header class="q-py-xs">⏰ Reporter</q-item-label>
|
||||
<q-item-label header class="q-py-xs">⏰ Reporter <HelpHint title="Reporter — mise en attente" text="Ce bouton reporte (snooze) ou ferme le billet ; ce n'est PAS le statut. Le statut se change dans le sélecteur du panneau de détail. « Reporter » repousse la date ; « En attente d'un autre ticket » met en pause jusqu'à la résolution d'un autre billet." /></q-item-label>
|
||||
<q-item clickable v-close-popup @click="postpone(1)"><q-item-section avatar><q-icon name="snooze" color="indigo" /></q-item-section><q-item-section>À demain (+1 jour)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="postpone(7)"><q-item-section avatar><q-icon name="snooze" color="indigo" /></q-item-section><q-item-section>Dans 7 jours</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="postponeUntil"><q-item-section avatar><q-icon name="event" color="indigo" /></q-item-section><q-item-section>À une date précise…</q-item-section></q-item>
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
import { computed, ref } from 'vue'
|
||||
import { Dialog, Notify } from 'quasar'
|
||||
import { updateDoc, createDoc } from 'src/api/erp'
|
||||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: { type: String, required: true }, // 'Issue' | 'Dispatch Job'
|
||||
|
|
|
|||
53
apps/ops/src/components/shared/UserAvatar.vue
Normal file
53
apps/ops/src/components/shared/UserAvatar.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<template>
|
||||
<!-- Avatar CANONIQUE d'un membre : photo si présente, sinon repli initiales +
|
||||
couleur déterministe (useFormatters). Réutiliser PARTOUT plutôt que de
|
||||
recoder q-avatar + initial() dans chaque page. -->
|
||||
<q-avatar :size="size + 'px'" :class="['user-avatar', { 'user-avatar--img': showImg }]"
|
||||
:style="showImg ? null : { background: bg, color: '#fff', fontSize: Math.round(size * 0.42) + 'px' }">
|
||||
<img v-if="showImg" :src="src" :alt="name || email" referrerpolicy="no-referrer" @error="failed = true">
|
||||
<template v-else>{{ ini }}</template>
|
||||
<q-tooltip v-if="tooltip && (name || email)">{{ name || email }}</q-tooltip>
|
||||
<slot />
|
||||
</q-avatar>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { staffColor, staffInitials } from 'src/composables/useFormatters'
|
||||
import { avatarVersion, hasAvatar, ensureAvatarIndex } from 'src/composables/useAvatar'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
const props = defineProps({
|
||||
email: { type: String, default: '' },
|
||||
name: { type: String, default: '' },
|
||||
size: { type: Number, default: 28 },
|
||||
tooltip: { type: Boolean, default: false },
|
||||
// Change de valeur après un upload → force le rechargement de l'image (cache-bust).
|
||||
version: { type: [Number, String], default: '' },
|
||||
})
|
||||
|
||||
const failed = ref(false)
|
||||
const src = computed(() => {
|
||||
if (!props.email) return ''
|
||||
// Version explicite (prop) sinon version partagée (bump après upload) → cache-bust.
|
||||
const ver = props.version || avatarVersion(props.email)
|
||||
const v = ver ? ('?v=' + ver) : ''
|
||||
return `${HUB_URL}/avatars/${encodeURIComponent(props.email.toLowerCase())}${v}`
|
||||
})
|
||||
// On ne tente le <img> QUE si l'index indique une photo pour ce courriel (évite un
|
||||
// 404 par membre sans photo) ; sinon initiales directement. @error → repli aussi.
|
||||
const showImg = computed(() => !!props.email && hasAvatar(props.email) && !failed.value)
|
||||
const bg = computed(() => staffColor(props.email || props.name))
|
||||
const ini = computed(() => staffInitials(props.name) || (props.email ? props.email[0].toUpperCase() : '?'))
|
||||
|
||||
// Réinitialise l'état d'échec quand la cible change (email/version).
|
||||
watch(src, () => { failed.value = false })
|
||||
|
||||
// Charge l'index d'avatars une fois (partagé/idempotent).
|
||||
onMounted(() => { ensureAvatarIndex() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-avatar { font-weight: 600; }
|
||||
.user-avatar--img :deep(img) { object-fit: cover; }
|
||||
</style>
|
||||
128
apps/ops/src/components/shared/UserProfileDialog.vue
Normal file
128
apps/ops/src/components/shared/UserProfileDialog.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
<template>
|
||||
<q-dialog v-model="open">
|
||||
<q-card style="min-width:360px;max-width:440px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<div class="text-h6">Mon profil</div>
|
||||
<q-space />
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section class="column items-center q-pt-md">
|
||||
<!-- Photo actuelle (grande) -->
|
||||
<UserAvatar :email="email" :name="nameDraft || name" :size="96" class="q-mb-sm" />
|
||||
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
class="hidden" @change="onFile">
|
||||
<div class="row q-gutter-sm q-mb-md">
|
||||
<q-btn dense no-caps unelevated color="primary" icon="photo_camera"
|
||||
:loading="uploading" label="Changer la photo" @click="pickFile" />
|
||||
<q-btn dense no-caps flat color="grey-7" icon="delete" label="Retirer"
|
||||
:disable="uploading" @click="removePhoto" />
|
||||
</div>
|
||||
|
||||
<!-- Nom complet éditable -->
|
||||
<q-input v-model="nameDraft" label="Nom complet" outlined dense class="full-width q-mb-sm"
|
||||
:disable="savingName" @keyup.enter="commitName">
|
||||
<template #append>
|
||||
<q-btn v-if="nameDraft.trim() && nameDraft.trim() !== name" flat dense round
|
||||
icon="check" color="primary" :loading="savingName" @click="commitName">
|
||||
<q-tooltip>Enregistrer le nom</q-tooltip>
|
||||
</q-btn>
|
||||
</template>
|
||||
</q-input>
|
||||
|
||||
<div class="full-width text-caption text-grey-6 q-mb-xs">{{ email }}</div>
|
||||
<div v-if="groups.length" class="row q-gutter-xs full-width q-mb-sm">
|
||||
<q-badge v-for="g in groups" :key="g" color="green-1" text-color="green-8">{{ g }}</q-badge>
|
||||
</div>
|
||||
|
||||
<!-- Profil employé lié (lecture seule ici ; édition dans Équipe/Administration) -->
|
||||
<div class="full-width q-pa-sm q-mt-xs" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px">
|
||||
<div v-if="employee" class="row items-center no-wrap">
|
||||
<q-icon name="badge" size="18px" color="green-7" class="q-mr-sm" />
|
||||
<div class="col">
|
||||
<div class="text-body2">{{ employee.employee_name }}</div>
|
||||
<div class="text-caption text-grey-6">{{ [employee.designation, employee.department].filter(Boolean).join(' · ') || 'Profil employé lié' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-caption text-grey-6">
|
||||
<q-icon name="link_off" size="14px" /> Aucun profil employé lié à votre compte — un administrateur peut le lier (Équipe / Administration).
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-5 q-mt-md">
|
||||
La photo (ou vos initiales) apparaît partout où votre nom est affiché : fils de discussion, lecteurs, équipe.
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import UserAvatar from './UserAvatar.vue'
|
||||
import { usePermissions } from 'src/composables/usePermissions'
|
||||
import { useAvatar } from 'src/composables/useAvatar'
|
||||
|
||||
const props = defineProps({ modelValue: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const open = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
|
||||
|
||||
const { userEmail, userName, permissions, HUB_URL } = usePermissions()
|
||||
const { uploadAvatar, deleteAvatar, fileToAvatarDataUrl } = useAvatar()
|
||||
|
||||
const email = computed(() => userEmail.value)
|
||||
const name = computed(() => userName.value)
|
||||
const groups = computed(() => permissions.value?.groups || [])
|
||||
const employee = computed(() => permissions.value?.employee || null)
|
||||
|
||||
const fileInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const savingName = ref(false)
|
||||
const nameDraft = ref('')
|
||||
|
||||
// (Ré)initialise le brouillon de nom à l'ouverture.
|
||||
watch(open, (v) => { if (v) nameDraft.value = name.value || '' })
|
||||
|
||||
function pickFile () { fileInput.value?.click() }
|
||||
|
||||
async function onFile (e) {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
e.target.value = '' // permet de re-choisir le même fichier
|
||||
if (!file) return
|
||||
uploading.value = true
|
||||
try {
|
||||
const dataUrl = await fileToAvatarDataUrl(file)
|
||||
await uploadAvatar(dataUrl)
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: 'Image illisible: ' + err.message })
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePhoto () {
|
||||
uploading.value = true
|
||||
try { await deleteAvatar() } finally { uploading.value = false }
|
||||
}
|
||||
|
||||
async function commitName () {
|
||||
const n = nameDraft.value.trim()
|
||||
if (!n || n === name.value) return
|
||||
savingName.value = true
|
||||
try {
|
||||
const r = await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(email.value) + '/name', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: n }),
|
||||
})
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status))
|
||||
if (permissions.value) permissions.value.name = n // reflète immédiatement dans l'app
|
||||
Notify.create({ type: 'positive', message: 'Nom mis à jour', timeout: 1500 })
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + err.message })
|
||||
} finally {
|
||||
savingName.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
<template>
|
||||
<!-- Quick actions -->
|
||||
<!-- Flow retiré : la relance de flow sera pilotée par l'automatisation (Réglages généraux), pas manuellement depuis le ticket. -->
|
||||
<div class="issue-actions q-mb-sm">
|
||||
<TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
|
||||
@changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" />
|
||||
<FlowQuickButton flat dense size="sm" icon="account_tree" label="Flow"
|
||||
tooltip="Lancer / modifier un flow pour ce ticket"
|
||||
category="incident" applies-to="Issue" trigger-event="on_issue_opened" />
|
||||
</div>
|
||||
|
||||
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic) -->
|
||||
<div class="cust-banner q-mb-sm">
|
||||
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic).
|
||||
Masqué quand on ouvre le ticket DEPUIS la fiche client (redondant : on connaît déjà le client). -->
|
||||
<div v-if="!hideCustomerLink" class="cust-banner q-mb-sm">
|
||||
<q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" />
|
||||
<template v-if="doc.customer">
|
||||
<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>
|
||||
|
|
@ -174,6 +173,36 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ INTERVENTION SUR SITE / DISPATCH ═══
|
||||
Modules RÉUTILISABLES (identiques au volet Planification) montés pour la tâche dispatch principale.
|
||||
Auto-affiché si signaux terrain (coords / billet legacy) ; sinon bascule manuelle. « Ne régresse pas vers un DetailModal nu ». -->
|
||||
<div v-if="primaryJob" class="q-mt-md onsite-section">
|
||||
<div class="row items-center q-mb-xs">
|
||||
<div class="info-block-title" style="margin-bottom:0"><q-icon name="construction" size="16px" class="q-mr-xs" />Intervention sur site</div>
|
||||
<q-space />
|
||||
<q-toggle v-model="showOnsite" dense size="sm" color="teal-6" label="Sur place">
|
||||
<q-tooltip>Afficher les détails terrain (carte, suivi GPS, durée, compétences, fil du billet) pour cette intervention</q-tooltip>
|
||||
</q-toggle>
|
||||
</div>
|
||||
<div v-if="showOnsite" class="onsite-body">
|
||||
<JobMapModule :name="primaryJob.name" :lat="primaryJob.latitude" :lon="primaryJob.longitude"
|
||||
:tech-id="primaryJob.assigned_tech || ''" :iso="primaryJobIso" :can-track="jobCanTrack" :today-iso="todayIso" />
|
||||
<GeofenceTimeline :name="primaryJob.name" />
|
||||
<DurationField :name="primaryJob.name" :dur-h="Number(primaryJob.duration_h) || 1" />
|
||||
<RequiredSkillsField :name="primaryJob.name" :skills="primaryJobSkills" :all-tags="allTags" :get-color="getTagColor" @create="onCreateTag" />
|
||||
<!-- Équipe / assignation de la tâche (module partagé). Lead via roster.assignJob (chemin simple, pas la pipeline board). -->
|
||||
<div class="q-mt-sm">
|
||||
<JobTeamField :name="primaryJob.name" :tech-id="primaryJob.assigned_tech || ''" :tech-name="primaryJobTechName"
|
||||
:tech-options="jobTechOptions" :can-onsite="true"
|
||||
@assign-lead="onJobAssignLead" @unassign-lead="onJobUnassignLead" />
|
||||
</div>
|
||||
<template v-if="primaryJob.legacy_ticket_id">
|
||||
<JobThread ref="jobThreadRef" :lid="primaryJob.legacy_ticket_id" />
|
||||
<JobReplyBox :name="primaryJob.name" :lid="primaryJob.legacy_ticket_id" @sent="() => jobThreadRef && jobThreadRef.reload()" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="q-mt-sm">
|
||||
<div class="info-block-title">Sujet</div>
|
||||
<InlineField :value="doc.subject" field="subject" doctype="Issue" :docname="docName"
|
||||
|
|
@ -308,13 +337,21 @@ import { HUB_URL } from 'src/config/hub'
|
|||
import { useTagCatalog } from 'src/composables/useTagCatalog'
|
||||
import InlineField from 'src/components/shared/InlineField.vue'
|
||||
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.vue'
|
||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||
import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
||||
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é)
|
||||
// Modules on-site / dispatch RÉUTILISABLES (mêmes composants que le volet Planification) — montés ici pour la tâche dispatch principale du ticket.
|
||||
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
|
||||
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
|
||||
import DurationField from 'src/components/shared/detail-sections/modules/DurationField.vue'
|
||||
import RequiredSkillsField from 'src/components/shared/detail-sections/modules/RequiredSkillsField.vue'
|
||||
import JobThread from 'src/components/shared/detail-sections/modules/JobThread.vue'
|
||||
import JobReplyBox from 'src/components/shared/detail-sections/modules/JobReplyBox.vue'
|
||||
import JobTeamField from 'src/components/shared/detail-sections/modules/JobTeamField.vue'
|
||||
import * as roster from 'src/api/roster'
|
||||
|
||||
const props = defineProps({
|
||||
doc: { type: Object, required: true },
|
||||
|
|
@ -324,6 +361,7 @@ const props = defineProps({
|
|||
comms: { type: Array, default: () => [] },
|
||||
files: { type: Array, default: () => [] },
|
||||
dispatchJobs: { type: Array, default: () => [] },
|
||||
hideCustomerLink: { type: Boolean, default: false }, // true = ouvert depuis la fiche client → masque la bannière client (redondante)
|
||||
})
|
||||
const emit = defineEmits(['navigate', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted'])
|
||||
|
||||
|
|
@ -512,6 +550,68 @@ async function saveAssignees (newList) {
|
|||
|
||||
onMounted(() => { loadUsers(); loadTagCatalog() })
|
||||
|
||||
// ── Intervention sur site / dispatch : modules montés pour la tâche dispatch PRINCIPALE du ticket ──
|
||||
// Principale = une tâche avec coordonnées OU billet legacy (aspect terrain) ; sinon la 1re racine ; sinon la 1re.
|
||||
const jobThreadRef = ref(null)
|
||||
const primaryJob = computed(() => {
|
||||
const jobs = props.dispatchJobs || []
|
||||
if (!jobs.length) return null
|
||||
return jobs.find(j => (j.latitude && j.longitude) || j.legacy_ticket_id) || jobs.find(j => !j.parent_job) || jobs[0]
|
||||
})
|
||||
// Signaux « terrain » → affichage AUTO de la section ; sinon bascule manuelle (« nécessite une visite sur place »).
|
||||
const onsiteAuto = computed(() => {
|
||||
const j = primaryJob.value; if (!j) return false
|
||||
const la = +j.latitude, lo = +j.longitude
|
||||
return (isFinite(la) && isFinite(lo) && (la || lo)) || !!j.legacy_ticket_id
|
||||
})
|
||||
const primaryJobIso = computed(() => String((primaryJob.value && primaryJob.value.scheduled_date) || '').slice(0, 10))
|
||||
const todayIso = computed(() => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }))
|
||||
const jobCanTrack = computed(() => {
|
||||
const iso = primaryJobIso.value; if (!iso) return false
|
||||
const n = todayIso.value
|
||||
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
|
||||
return iso === n || iso === yest
|
||||
})
|
||||
const primaryJobSkills = computed(() => { const s = primaryJob.value && primaryJob.value.required_skill; return s ? [s] : [] })
|
||||
|
||||
// Équipe / assignation de la tâche dispatch depuis le ticket. Techs chargés à la demande (quand la section s'ouvre).
|
||||
const jobTechs = ref([])
|
||||
const jobTechOptions = computed(() => (jobTechs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 4).join(', ') : ''), value: t.id })))
|
||||
const primaryJobTechName = computed(() => { const id = primaryJob.value && primaryJob.value.assigned_tech; if (!id) return ''; const t = (jobTechs.value || []).find(x => x.id === id); return (t && t.name) || String(id) })
|
||||
async function loadJobTechs () {
|
||||
if (jobTechs.value.length) return
|
||||
try { const r = await roster.listTechnicians(); jobTechs.value = Array.isArray(r) ? r : ((r && r.technicians) || []) } catch { jobTechs.value = [] }
|
||||
}
|
||||
// Lead : chemin roster SIMPLE (assignJob / unassign) — pas la pipeline board (assignNames/sibling-grouping) qui vit dans Planification.
|
||||
async function onJobAssignLead (techId) {
|
||||
const j = primaryJob.value; if (!techId || !j || !j.name) return
|
||||
try {
|
||||
await roster.assignJob(j.name, techId, primaryJobIso.value || undefined)
|
||||
j.assigned_tech = techId // objet réactif (issu de useDetailModal) → met à jour la bannière + le module
|
||||
const t = (jobTechs.value || []).find(x => x.id === techId)
|
||||
Notify.create({ type: 'positive', message: 'Assigné à ' + ((t && t.name) || techId), timeout: 1800 })
|
||||
emit('dispatch-updated', j.name, { assigned_tech: techId })
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur assignation: ' + e.message, timeout: 3000 }) }
|
||||
}
|
||||
async function onJobUnassignLead () {
|
||||
const j = primaryJob.value; if (!j || !j.name) return
|
||||
try {
|
||||
await roster.unassignJobRoster(j.name); j.assigned_tech = ''
|
||||
Notify.create({ type: 'info', message: 'Renvoyé au pool (non assigné)', timeout: 1600 })
|
||||
emit('dispatch-updated', j.name, { assigned_tech: '' })
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 }) }
|
||||
}
|
||||
watch(showOnsite, v => { if (v) loadJobTechs() })
|
||||
// Bascule persistée par ticket (localStorage — pas de champ ERPNext requis). Défaut = auto-détection.
|
||||
const showOnsite = ref(false)
|
||||
const onsiteKey = () => 'ops-ticket-onsite-' + (props.docName || '')
|
||||
watch([primaryJob, () => props.docName], () => {
|
||||
let stored = null
|
||||
try { stored = localStorage.getItem(onsiteKey()) } catch { /* localStorage indisponible */ }
|
||||
showOnsite.value = stored != null ? stored === '1' : onsiteAuto.value
|
||||
}, { immediate: true })
|
||||
watch(showOnsite, v => { try { localStorage.setItem(onsiteKey(), v ? '1' : '0') } catch { /* noop */ } })
|
||||
|
||||
// ── Tags — catalogue UNIFIÉ (Dispatch Tag ∪ compétences roster) via TagEditor ──
|
||||
// Le catalogue et son CRUD vivent dans useTagCatalog (source unique, réutilisable).
|
||||
const { allTags, dispatchTags, getColor: getTagColor, load: loadTagCatalog, createTag: createCatTag, recolorTag, renameTag: renameCatTag, removeTag: removeCatTag } = useTagCatalog()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<!--
|
||||
DurationField — durée estimée d'un job (persiste duration_h via roster.updateJob).
|
||||
Module RÉUTILISABLE extrait du volet jobDetail. Autonome : écrit directement au hub, émet `updated` (le board
|
||||
peut alors rafraîchir son override d'optimisation / ré-optimiser dans son handler ; le détail ticket ignore).
|
||||
-->
|
||||
<template>
|
||||
<div class="jd-dur row items-center q-gutter-sm q-my-sm">
|
||||
<q-icon name="timer" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Durée estimée</span>
|
||||
<q-input dense outlined type="number" step="0.25" min="0" v-model.number="local" style="width:96px" suffix="h"
|
||||
:loading="busy" @change="save" @keyup.enter="save" />
|
||||
<span class="text-caption text-grey-5">≈ {{ fmtMin(Math.round((local || 0) * 60)) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
|
||||
// Durée lisible (source unique locale — fmtMin n'est pas dans useFormatters).
|
||||
const fmtMin = (m) => { m = Math.round(Number(m) || 0); const h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? String(mm).padStart(2, '0') : '')) : (mm + 'min') }
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||
durH: { type: Number, default: 1 },
|
||||
})
|
||||
const emit = defineEmits(['updated'])
|
||||
|
||||
const local = ref(props.durH)
|
||||
const busy = ref(false)
|
||||
watch(() => props.durH, v => { local.value = v })
|
||||
|
||||
async function save () {
|
||||
const h = Math.round((Number(local.value) || 0) * 100) / 100
|
||||
if (h <= 0 || !props.name) return
|
||||
busy.value = true
|
||||
try {
|
||||
await roster.updateJob(props.name, { duration_h: h })
|
||||
emit('updated', { duration_h: h }) // le board persiste son override / ré-optimise ; le ticket ignore
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Durée non enregistrée : ' + (e && e.message), timeout: 3000 })
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
<!--
|
||||
GeofenceTimeline — suivi terrain (géofencing GPS) façon suivi de colis : Assigné → En route → Arrivé → Reparti.
|
||||
Module RÉUTILISABLE extrait du volet jobDetail (PlanificationPage). Autonome : il charge lui-même la timeline
|
||||
via roster.jobGeofence(name) si aucun objet `geofence` n'est fourni. Aucune dépendance à l'état de la page.
|
||||
N'affiche rien si le job n'a pas d'état de géofence (pas de bruit).
|
||||
-->
|
||||
<template>
|
||||
<div v-if="gf && gf.state" class="jd-geo">
|
||||
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
|
||||
<div class="jd-geo-track">
|
||||
<div v-for="s in timeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
|
||||
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
|
||||
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import * as roster from 'src/api/roster'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||
geofence: { type: Object, default: null }, // objet déjà chargé { state, events } — sinon on le fetch
|
||||
})
|
||||
|
||||
const GEO_STEPS = [
|
||||
{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' },
|
||||
{ k: 'en_route', label: 'En route', icon: 'directions_car' },
|
||||
{ k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' },
|
||||
{ k: 'departed', label: 'Reparti', icon: 'check_circle' },
|
||||
]
|
||||
|
||||
const loaded = ref(null)
|
||||
const gf = computed(() => props.geofence || loaded.value)
|
||||
|
||||
const timeline = computed(() => {
|
||||
const g = gf.value; const events = (g && g.events) || []
|
||||
const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null }
|
||||
const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 }
|
||||
const curIdx = order[(g && g.state) || 'assigned'] ?? 0
|
||||
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
|
||||
})
|
||||
|
||||
async function load () {
|
||||
if (props.geofence || !props.name) return
|
||||
try { loaded.value = await roster.jobGeofence(props.name) } catch (e) { loaded.value = null }
|
||||
}
|
||||
watch(() => props.name, () => { loaded.value = null; load() })
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; }
|
||||
.jd-geo-track { display: flex; align-items: flex-start; }
|
||||
.jd-geo-step { flex: 1; text-align: center; position: relative; }
|
||||
.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; }
|
||||
.jd-geo-step:first-child::before { display: none; }
|
||||
.jd-geo-step.done::before { background: #26a69a; }
|
||||
.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; }
|
||||
.jd-geo-step.done .jd-geo-ic { background: #26a69a; }
|
||||
.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); }
|
||||
.jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; }
|
||||
.jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; }
|
||||
.jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<!--
|
||||
JobMapModule — carte du job (repère) + tracé GPS réel (Traccar) + liens géo externes (Google Maps / Street View).
|
||||
Module RÉUTILISABLE extrait du volet jobDetail (PlanificationPage). Autonome : MapLibre/OSM auto-hébergé via createPlanMap,
|
||||
tracé via roster.traccarTrack. Carte repliée par défaut (non requise pour lire le ticket) — dépliable au besoin.
|
||||
Les points de contexte « à proximité » sont OPTIONNELS (prop `nearby`) : le board les fournit, le détail ticket non.
|
||||
-->
|
||||
<template>
|
||||
<div class="q-mb-sm">
|
||||
<div v-if="hasCoords" class="jd-meta q-mb-xs">
|
||||
<div class="jd-geolinks">
|
||||
<a :href="mapsUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="satellite_alt" size="14px" /> Carte / satellite</a>
|
||||
<a :href="streetViewUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="streetview" size="14px" /> Street View</a>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn flat dense no-caps size="sm" color="grey-7" :icon="show ? 'expand_less' : 'map'"
|
||||
:label="show ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="show = !show" />
|
||||
<div v-show="show">
|
||||
<div ref="mapEl" class="jd-map"></div>
|
||||
<div v-if="canTrack" class="jd-track-row">
|
||||
<q-toggle v-model="showTrack" dense size="sm" color="warning" />
|
||||
<span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span>
|
||||
<span class="text-caption text-grey-6">{{ trackMsg }}</span>
|
||||
</div>
|
||||
<div v-else class="text-caption text-grey-5 q-mb-sm">Tracé GPS réel : aujourd'hui / hier seulement (Traccar).</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { createPlanMap } from 'src/config/basemap'
|
||||
import * as roster from 'src/api/roster'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' }, // docname (non utilisé pour le rendu ; réservé)
|
||||
lat: { type: [Number, String], default: null },
|
||||
lon: { type: [Number, String], default: null },
|
||||
techId: { type: String, default: '' }, // pour le tracé GPS
|
||||
iso: { type: String, default: '' }, // jour du tracé (YYYY-MM-DD)
|
||||
canTrack: { type: Boolean, default: false }, // Traccar dispo (aujourd'hui/hier seulement)
|
||||
todayIso: { type: String, default: '' }, // pour borner le tracé au « maintenant » si c'est aujourd'hui
|
||||
nearby: { type: Array, default: () => [] }, // points pâles de contexte : [[lon, lat, subject], …]
|
||||
})
|
||||
|
||||
const mapEl = ref(null); let _map = null
|
||||
const show = ref(false)
|
||||
const showTrack = ref(false); const trackMsg = ref('')
|
||||
|
||||
const laNum = computed(() => +props.lat); const loNum = computed(() => +props.lon)
|
||||
const hasCoords = computed(() => isFinite(laNum.value) && isFinite(loNum.value) && (laNum.value !== 0 || loNum.value !== 0))
|
||||
const mapsUrl = computed(() => `https://www.google.com/maps/search/?api=1&query=${props.lat},${props.lon}`)
|
||||
const streetViewUrl = computed(() => `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${props.lat},${props.lon}`)
|
||||
|
||||
async function initMap () {
|
||||
if (!mapEl.value) return
|
||||
if (_map) { try { _map.remove() } catch (e) {} _map = null }
|
||||
const hasLL = hasCoords.value
|
||||
const mapboxgl = window.mapboxgl // installé par createPlanMap
|
||||
_map = createPlanMap(mapEl.value, { center: hasLL ? [loNum.value, laNum.value] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
|
||||
_map.on('load', () => {
|
||||
_map.resize()
|
||||
if (hasLL && props.nearby.length) {
|
||||
_map.addSource('jd-near', { type: 'geojson', data: { type: 'FeatureCollection', features: props.nearby.map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p[0], p[1]] }, properties: { t: p[2] } })) } })
|
||||
_map.addLayer({ id: 'jd-near', type: 'circle', source: 'jd-near', paint: { 'circle-radius': 5, 'circle-color': '#94a3b8', 'circle-opacity': 0.45, 'circle-stroke-color': '#fff', 'circle-stroke-width': 1 } })
|
||||
_map.on('mouseenter', 'jd-near', () => { _map.getCanvas().style.cursor = 'pointer' })
|
||||
_map.on('mouseleave', 'jd-near', () => { _map.getCanvas().style.cursor = '' })
|
||||
_map.on('click', 'jd-near', (e) => { const p = e.features[0].properties; new mapboxgl.Popup({ offset: 10 }).setLngLat(e.features[0].geometry.coordinates).setHTML('<div style="font-size:11px;color:#475569">' + String(p.t || '').replace(/[<>&]/g, '') + '</div>').addTo(_map) })
|
||||
}
|
||||
if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([loNum.value, laNum.value]).addTo(_map)
|
||||
_map.addSource('jd-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
_map.addLayer({ id: 'jd-track-halo', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 8, 'line-opacity': 0.22 } })
|
||||
_map.addLayer({ id: 'jd-track-l', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3, 'line-opacity': 0.78 } })
|
||||
if (showTrack.value) loadTrack()
|
||||
})
|
||||
}
|
||||
async function loadTrack () {
|
||||
trackMsg.value = '…'
|
||||
const iso = props.iso; if (!iso || !props.techId || !_map) { trackMsg.value = ''; return }
|
||||
const from = new Date(iso + 'T00:00:00').toISOString()
|
||||
const to = (props.todayIso && iso === props.todayIso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
|
||||
try {
|
||||
const r = await roster.traccarTrack(props.techId, from, to); const coords = (r && r.coords) || []
|
||||
const src = _map.getSource('jd-track')
|
||||
if (src) src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
|
||||
trackMsg.value = coords.length >= 2 ? (coords.length + ' points GPS') : "Aucun tracé ce jour (ou pas d'appareil)."
|
||||
} catch (e) { trackMsg.value = 'Tracé indisponible' }
|
||||
}
|
||||
|
||||
function destroy () { if (_map) { try { _map.remove() } catch (e) {} _map = null } }
|
||||
watch(show, (on) => { if (on) nextTick(() => setTimeout(initMap, 200)); else destroy() })
|
||||
watch(showTrack, (on) => { if (!_map) return; if (on) loadTrack(); else { const s = _map.getSource('jd-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); trackMsg.value = '' } })
|
||||
onBeforeUnmount(destroy)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.jd-meta { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #455; }
|
||||
.jd-geolinks { display: flex; padding-left: 22px; gap: 14px; }
|
||||
.jd-geolink { display: inline-flex; align-items: center; gap: 3px; font-size: 12px; color: #2563eb; text-decoration: none; font-weight: 500; }
|
||||
.jd-geolink:hover { text-decoration: underline; }
|
||||
.jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; }
|
||||
.jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<!--
|
||||
JobReplyBox — répondre au fil d'un job (note INTERNE par défaut ; au client si coché, quand un billet legacy est lié).
|
||||
Module RÉUTILISABLE extrait du volet jobDetail. Poste via roster.postJobComment ; émet `sent` pour que le parent
|
||||
recharge le fil (ex. JobThread.reload()). Attribution = agent connecté (prénom+nom) via usePermissions.
|
||||
-->
|
||||
<template>
|
||||
<div v-if="lid || name" class="q-mt-sm">
|
||||
<q-input v-model="reply" dense outlined type="textarea" autogrow placeholder="Écrire une note / réponse…"
|
||||
:input-style="{ minHeight: '48px' }" @keydown.ctrl.enter="send" @keydown.meta.enter="send" />
|
||||
<div class="row items-center q-mt-xs">
|
||||
<q-checkbox v-if="lid" v-model="replyPublic" dense size="sm" label="Envoyer aussi au client" />
|
||||
<q-space />
|
||||
<q-btn dense unelevated color="primary" no-caps icon="send"
|
||||
:label="replyPublic ? 'Répondre au client' : 'Note interne'"
|
||||
:disable="!reply.trim()" :loading="sending" @click="send" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
import { usePermissions } from 'src/composables/usePermissions'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||
lid: { type: [String, Number], default: null }, // n° ticket legacy (permet « au client »)
|
||||
})
|
||||
const emit = defineEmits(['sent'])
|
||||
|
||||
const { userName } = usePermissions()
|
||||
const reply = ref('')
|
||||
const replyPublic = ref(false)
|
||||
const sending = ref(false)
|
||||
|
||||
async function send () {
|
||||
const text = reply.value.trim(); if (!text || sending.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const r = await roster.postJobComment({ job: props.name, lid: props.lid, text, isPublic: replyPublic.value, agentName: userName.value || '' })
|
||||
if (r && r.ok) {
|
||||
const wasPublic = replyPublic.value
|
||||
reply.value = ''; replyPublic.value = false
|
||||
Notify.create({ type: 'positive', message: wasPublic ? 'Réponse envoyée au client' : 'Note interne ajoutée', timeout: 2200 })
|
||||
emit('sent', { public: wasPublic })
|
||||
} else { Notify.create({ type: 'negative', message: (r && r.error) || "Échec de l'envoi", timeout: 3000 }) }
|
||||
} catch (e) { Notify.create({ type: 'negative', message: "Échec de l'envoi", timeout: 3000 }) } finally { sending.value = false }
|
||||
}
|
||||
</script>
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
<!--
|
||||
JobTeamField — équipe d'un Dispatch Job (assigné + assistants/renfort + suiveurs) via le champ partagé AssignmentField.
|
||||
Module RÉUTILISABLE extrait du volet jobDetail. Il ENCAPSULE la gestion des assistants (roster.addAssistant /
|
||||
removeAssistant / getJobTeam) et émet `updated` après chaque changement (le parent rafraîchit l'occupation).
|
||||
L'assignation du LEAD reste au parent (émise via `assign-lead` / `unassign-lead`) : le board utilise sa pipeline
|
||||
assignNames (groupes / géorepérage), le détail ticket un chemin simple. Slot #right passé à AssignmentField (ex. statut).
|
||||
-->
|
||||
<template>
|
||||
<AssignmentField
|
||||
doctype="Dispatch Job" :doc-name="name"
|
||||
:assignee="assignee" :assistants="assistants"
|
||||
:tech-options="techOptions" :can-onsite="canOnsite" :assist-loading="teamLoading || busy"
|
||||
@assign="v => $emit('assign-lead', v)" @unassign="$emit('unassign-lead')"
|
||||
@set-assistant="onSetAssistant" @remove-assistant="onRemoveAssistant">
|
||||
<template #right><slot name="right" /></template>
|
||||
</AssignmentField>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
import AssignmentField from 'src/components/shared/AssignmentField.vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||
techId: { type: String, default: '' }, // lead assigné (id)
|
||||
techName: { type: String, default: '' }, // lead assigné (nom)
|
||||
team: { type: Array, default: null }, // assistants [{ tech_id, tech_name, pinned }] — si null, le module charge lui-même
|
||||
techOptions: { type: Array, default: () => [] }, // suggestions (techs + compétences)
|
||||
canOnsite: { type: Boolean, default: true }, // niveau « Sur place » (bloc horaire) dispo ?
|
||||
teamLoading: { type: Boolean, default: false },
|
||||
})
|
||||
const emit = defineEmits(['assign-lead', 'unassign-lead', 'updated'])
|
||||
|
||||
const localTeam = ref(props.team || [])
|
||||
const busy = ref(false)
|
||||
watch(() => props.team, v => { if (v) localTeam.value = v })
|
||||
|
||||
const assignee = computed(() => (props.techName || props.techId) ? { id: props.techId, name: props.techName } : null)
|
||||
// Nom d'assistant robuste : une vieille ligne peut porter tech_name = « undefined »/« null » → repli sur l'id.
|
||||
function assistantName (a) { const bad = v => !v || v === 'undefined' || v === 'null'; if (a && !bad(a.tech_name)) return a.tech_name; return (a && !bad(a.tech_id)) ? a.tech_id : '—' }
|
||||
// onsite = pinned (bloc horaire réservé). Assistant simple (pinned 0) = sur l'équipe sans bloc.
|
||||
const assistants = computed(() => (localTeam.value || []).map(a => ({ id: a.tech_id, name: assistantName(a), onsite: !!a.pinned })))
|
||||
|
||||
async function reloadTeam () {
|
||||
if (!props.name) return
|
||||
try { const r = await roster.getJobTeam(props.name); localTeam.value = r.assistants || [] } catch (e) { /* garde l'existant */ }
|
||||
}
|
||||
|
||||
// Ajouter / mettre à jour un assistant : { value=id, label, onsite }. Le hub `add` remplace la ligne du même tech_id
|
||||
// → sert aussi à CHANGER le niveau (assistant ↔ sur place). Nom robuste depuis le libellé de l'option.
|
||||
async function onSetAssistant ({ value, label, onsite } = {}) {
|
||||
const id = value; if (!id || !props.name) return
|
||||
const name = (label && String(label).split(' · ')[0].trim()) || String(id)
|
||||
busy.value = true
|
||||
try {
|
||||
await roster.addAssistant(props.name, { tech_id: String(id), tech_name: name, duration_h: 0, pinned: onsite ? 1 : 0 })
|
||||
await reloadTeam()
|
||||
emit('updated', localTeam.value)
|
||||
Notify.create({ type: 'positive', icon: onsite ? 'construction' : 'group_add', message: name + (onsite ? ' — sur place (bloc réservé)' : ' — assistant'), timeout: 2400 })
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur équipe : ' + (e && e.message), timeout: 3000 }) } finally { busy.value = false }
|
||||
}
|
||||
async function onRemoveAssistant (techId) {
|
||||
if (!props.name) return
|
||||
busy.value = true
|
||||
try {
|
||||
await roster.removeAssistant(props.name, techId); await reloadTeam(); emit('updated', localTeam.value)
|
||||
Notify.create({ type: 'info', message: 'Assistant retiré', timeout: 1600 })
|
||||
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur : ' + (e && e.message), timeout: 3000 }) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { if (props.team == null && props.name) reloadTeam() })
|
||||
</script>
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!--
|
||||
JobThread — fil du billet (osTicket legacy) d'un job, PLUS RÉCENT EN HAUT. Module RÉUTILISABLE extrait du volet jobDetail.
|
||||
Autonome : charge via roster.ticketThread(lid) si aucun `thread` n'est fourni. Expose reload() (le parent l'appelle
|
||||
après un envoi via JobReplyBox). Repli sur le corps brut si le fil est vide.
|
||||
-->
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">
|
||||
Commentaires / fil du billet
|
||||
<span v-if="messages.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· plus récent en haut</span>
|
||||
</div>
|
||||
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
|
||||
<template v-else-if="messages.length">
|
||||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ messages.length }} message(s){{ threadStatus ? ' · ' + threadStatus : '' }}</div>
|
||||
<div v-for="(m, mi) in messages" :key="mi" class="de-msg" :class="{ 'de-msg-latest': mi === 0 }">
|
||||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><span v-if="mi === 0 && messages.length > 1" class="de-msg-badge">dernier</span></div>
|
||||
<div class="de-msg-txt">{{ m.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="detail" class="jd-detail">{{ detail }}</div>
|
||||
<div v-else class="text-grey-6 q-pa-md text-center">{{ lid ? (loadErr ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import * as roster from 'src/api/roster'
|
||||
import { formatDateTime as fmtDT } from 'src/composables/useFormatters'
|
||||
|
||||
const props = defineProps({
|
||||
lid: { type: [String, Number], default: null }, // n° ticket legacy (osTicket)
|
||||
thread: { type: Object, default: null }, // { messages, status } déjà chargé — sinon on fetch
|
||||
detail: { type: String, default: '' }, // corps brut de repli (si aucun message)
|
||||
})
|
||||
|
||||
const loaded = ref(null)
|
||||
const loading = ref(false)
|
||||
const loadErr = ref(false)
|
||||
const activeThread = computed(() => props.thread || loaded.value)
|
||||
const threadStatus = computed(() => (activeThread.value && activeThread.value.status) || '')
|
||||
// Fil PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
|
||||
const messages = computed(() => { const m = (activeThread.value && activeThread.value.messages) || []; return m.slice().reverse() })
|
||||
|
||||
async function reload () {
|
||||
if (props.thread || !props.lid) return
|
||||
loading.value = true; loadErr.value = false
|
||||
try { loaded.value = await roster.ticketThread(props.lid) } catch (e) { loaded.value = { error: true, messages: [] }; loadErr.value = true } finally { loading.value = false }
|
||||
}
|
||||
watch(() => props.lid, () => { loaded.value = null; reload() })
|
||||
onMounted(reload)
|
||||
defineExpose({ reload })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
|
||||
.de-msg:first-of-type { border-top: none; }
|
||||
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
|
||||
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
|
||||
.de-msg-latest { background: #f3f0fb; border-radius: 6px; padding: 6px 8px; margin-bottom: 4px; }
|
||||
.de-msg-badge { display: inline-block; margin-left: 6px; font-size: 9px; font-weight: 700; color: #fff; background: #7e57c2; border-radius: 4px; padding: 0 5px; vertical-align: middle; }
|
||||
.jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; }
|
||||
</style>
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<!--
|
||||
RequiredSkillsField — compétences REQUISES d'un job (store hub /roster/job-skills, PAS un champ ERPNext).
|
||||
Le tech doit les avoir TOUTES ; la 1re = principale (dispatch auto). Module RÉUTILISABLE extrait du volet jobDetail.
|
||||
Réutilise le TagEditor partagé. Le catalogue de tags + la fonction couleur sont fournis par le parent
|
||||
(board = tagCatalog/getTagColor ; ticket = useTagCatalog) — pas de source dupliquée ici.
|
||||
-->
|
||||
<template>
|
||||
<div class="jd-skill row items-center q-gutter-sm q-mb-xs">
|
||||
<q-icon name="construction" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Compétences requises</span>
|
||||
<TagEditor :model-value="skills" :all-tags="allTags" :get-color="getColor" :can-edit="false"
|
||||
placeholder="Ajouter une compétence requise…" style="min-width:220px;flex:1"
|
||||
@update:model-value="onChange" @create="$emit('create', $event)" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Notify } from 'quasar'
|
||||
import * as roster from 'src/api/roster'
|
||||
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||
|
||||
const props = defineProps({
|
||||
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||
skills: { type: Array, default: () => [] },
|
||||
allTags: { type: Array, default: () => [] },
|
||||
getColor: { type: Function, default: () => '#6b7280' },
|
||||
})
|
||||
const emit = defineEmits(['updated', 'create'])
|
||||
|
||||
// TagEditor émet un tableau de libellés (ou de {tag}) ; on tolère aussi une CSV (rétro-compat).
|
||||
function normSkillList (list) {
|
||||
const raw = Array.isArray(list) ? list : String(list || '').split(',')
|
||||
return [...new Set(raw.map(x => (typeof x === 'string' ? x : (x && x.tag) || '')).map(s => String(s).trim()).filter(Boolean))]
|
||||
}
|
||||
|
||||
async function onChange (list) {
|
||||
const arr = normSkillList(list)
|
||||
if (!props.name) { emit('updated', arr); return }
|
||||
try {
|
||||
await roster.setJobSkills(props.name, arr)
|
||||
emit('updated', arr) // le parent rafraîchit son pool si besoin
|
||||
} catch (e) {
|
||||
Notify.create({ type: 'negative', message: 'Compétences non enregistrées : ' + (e && e.message), timeout: 3000 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
88
apps/ops/src/composables/useAvatar.js
Normal file
88
apps/ops/src/composables/useAvatar.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { reactive } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
// Store partagé (module-level) : un seul état d'avatars pour toute l'app.
|
||||
// · hasPhoto[email] = true → ce courriel A une photo (sinon on n'affiche QUE les initiales,
|
||||
// sans tenter de <img> → zéro requête 404 pour les membres sans photo).
|
||||
// · versions[email] → jeton de cache-bust (bump après upload/suppression).
|
||||
const hasPhoto = reactive({})
|
||||
const versions = reactive({})
|
||||
|
||||
function keyOf (email) { return String(email || '').toLowerCase() }
|
||||
export function avatarVersion (email) { return versions[keyOf(email)] || '' }
|
||||
export function hasAvatar (email) { return !!hasPhoto[keyOf(email)] }
|
||||
function bump (email, v) { versions[keyOf(email)] = v || Date.now() }
|
||||
|
||||
// Index chargé une seule fois (promesse singleton) : GET /avatars → { avatars: { email: version } }.
|
||||
// Renseigne hasPhoto + versions. Idempotent : plusieurs <UserAvatar> peuvent l'appeler.
|
||||
let _indexP = null
|
||||
export function ensureAvatarIndex () {
|
||||
if (!_indexP) {
|
||||
_indexP = fetch(HUB_URL + '/avatars')
|
||||
.then(r => r.ok ? r.json() : { avatars: {} })
|
||||
.then(data => {
|
||||
for (const [email, v] of Object.entries(data.avatars || {})) {
|
||||
hasPhoto[keyOf(email)] = true
|
||||
versions[keyOf(email)] = v
|
||||
}
|
||||
})
|
||||
.catch(() => { _indexP = null }) // échec → réessayable au prochain montage
|
||||
}
|
||||
return _indexP
|
||||
}
|
||||
|
||||
// Réduit une image (File/Blob) à un carré <= max px, en JPEG base64 (data URL).
|
||||
// Garde les avatars petits (< 1 Mo) et cohérents, sans dépendance externe.
|
||||
export async function fileToAvatarDataUrl (file, max = 256, quality = 0.85) {
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const fr = new FileReader()
|
||||
fr.onload = () => resolve(fr.result)
|
||||
fr.onerror = reject
|
||||
fr.readAsDataURL(file)
|
||||
})
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const i = new Image()
|
||||
i.onload = () => resolve(i)
|
||||
i.onerror = reject
|
||||
i.src = dataUrl
|
||||
})
|
||||
const side = Math.min(img.width, img.height) // recadrage carré centré
|
||||
const sx = (img.width - side) / 2
|
||||
const sy = (img.height - side) / 2
|
||||
const out = Math.min(max, side)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = out; canvas.height = out
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.drawImage(img, sx, sy, side, side, 0, 0, out, out)
|
||||
return canvas.toDataURL('image/jpeg', quality)
|
||||
}
|
||||
|
||||
export function useAvatar () {
|
||||
async function uploadAvatar (dataUrl, { as } = {}) {
|
||||
const url = HUB_URL + '/avatars' + (as ? ('?as=' + encodeURIComponent(as)) : '')
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dataUrl }),
|
||||
})
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (!r.ok) { Notify.create({ type: 'negative', message: 'Photo: ' + (data.error || ('HTTP ' + r.status)) }); return false }
|
||||
hasPhoto[keyOf(data.email || as)] = true
|
||||
bump(data.email || as, data.version)
|
||||
Notify.create({ type: 'positive', message: 'Photo de profil mise à jour', timeout: 1500 })
|
||||
return true
|
||||
}
|
||||
|
||||
async function deleteAvatar ({ as } = {}) {
|
||||
const url = HUB_URL + '/avatars' + (as ? ('?as=' + encodeURIComponent(as)) : '')
|
||||
const r = await fetch(url, { method: 'DELETE' })
|
||||
const data = await r.json().catch(() => ({}))
|
||||
if (!r.ok) { Notify.create({ type: 'negative', message: 'Photo: ' + (data.error || ('HTTP ' + r.status)) }); return false }
|
||||
hasPhoto[keyOf(data.email || as)] = false
|
||||
bump(data.email || as)
|
||||
return true
|
||||
}
|
||||
|
||||
return { uploadAvatar, deleteAvatar, fileToAvatarDataUrl, avatarVersion }
|
||||
}
|
||||
|
|
@ -79,7 +79,8 @@ export function useDetailModal () {
|
|||
}).catch(() => []),
|
||||
listDocs('Dispatch Job', {
|
||||
filters: { source_issue: name },
|
||||
fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'job_type', 'priority', 'depends_on', 'duration_h', 'parent_job', 'step_order', 'on_open_webhook', 'on_close_webhook'],
|
||||
// + champs pour la détection de capacités on-site/dispatch dans IssueDetail (carte, géofence, durée, compétences, fil legacy)
|
||||
fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'job_type', 'priority', 'depends_on', 'duration_h', 'parent_job', 'step_order', 'on_open_webhook', 'on_close_webhook', 'latitude', 'longitude', 'legacy_ticket_id', 'required_skill', 'address', 'service_location', 'customer', 'customer_name'],
|
||||
limit: 50, orderBy: 'step_order asc, creation asc',
|
||||
}).catch(() => []),
|
||||
)
|
||||
|
|
|
|||
147
apps/ops/src/composables/useJobPool.js
Normal file
147
apps/ops/src/composables/useJobPool.js
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { ref, computed } from 'vue'
|
||||
|
||||
/**
|
||||
* useJobPool — cerveau UNIQUE des pools « Jobs à assigner » (panneau flottant, colonne Jour/kanban, liste mobile).
|
||||
*
|
||||
* Historique : trois réimplémentations divergentes (assignJobsFiltered/assignGroups, kanbanPoolView, poolJobs) filtraient
|
||||
* et triaient le MÊME tableau (assignPanel.jobs). Ce composable les fusionne : une instance PAR surface (créée et détenue
|
||||
* par la page → la carte du panneau flottant garde sa source de vérité en lisant `filteredJobs`). Le composant JobPool.vue
|
||||
* ne fait que rendre `groups`/`filteredJobs` + les badges. Toute la logique d'assignation/drag reste dans la page.
|
||||
*
|
||||
* @param {Function} jobsGetter () => Array — source réactive (ex. () => assignPanel.jobs)
|
||||
* @param {Object} deps { jobCity, jobLocKey, todayISO, fmtDueLabel, distOf? } — helpers PURS de la page (source unique)
|
||||
* @param {Object} opts { variant:'floating'|'docked'|'mobile', defaultSort?, selectedDay?:()=>iso }
|
||||
*/
|
||||
export function useJobPool (jobsGetter, deps, opts = {}) {
|
||||
const { jobCity, jobLocKey, todayISO, fmtDueLabel } = deps
|
||||
const distOf = deps.distOf || null // (j) => km depuis le dépôt — seulement le variant « docked » l'utilise
|
||||
const variant = opts.variant || 'floating'
|
||||
const selectedDay = opts.selectedDay || (() => null)
|
||||
|
||||
const PRIO_RANK = { urgent: 0, high: 1, 'élevée': 1, elevee: 1, medium: 2, moyenne: 2, normal: 2, low: 3, basse: 3 }
|
||||
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||
const pr = j => (PRIO_RANK[String(j.priority || '').toLowerCase()] ?? 2)
|
||||
const durMin = j => (+j.est_min || (+j.duration_h || 1) * 60)
|
||||
|
||||
// État de vue (PAR instance, vit dans la page → survit au démontage du composant lors d'un changement de vue).
|
||||
const skillFilter = ref([]) // compétences cochées (vide = toutes)
|
||||
const dateFilter = ref([]) // isos de date DUE cochés ('__overdue__' = toutes passées)
|
||||
const sort = ref(opts.defaultSort || (variant === 'mobile' ? 'smart' : variant === 'docked' ? 'prio' : 'date'))
|
||||
const sortDir = ref('asc')
|
||||
const search = ref('')
|
||||
const collapsed = ref(new Set())
|
||||
|
||||
const jobs = computed(() => jobsGetter() || [])
|
||||
|
||||
// Chips compétence : {k,n} par required_skill (le plus fréquent d'abord).
|
||||
const skillChips = computed(() => {
|
||||
const m = {}
|
||||
for (const j of jobs.value) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 }
|
||||
return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n)
|
||||
})
|
||||
|
||||
// Chips date DUE : dates passées regroupées en « ⏰ en retard », futures + « Sans date » séparées.
|
||||
const dateChips = computed(() => {
|
||||
const t = todayISO(); const m = {}
|
||||
for (const j of jobs.value) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 }
|
||||
const out = []; let overdueN = 0
|
||||
for (const [iso, n] of Object.entries(m)) {
|
||||
if (iso !== 'Sans date' && iso < t) { overdueN += n; continue }
|
||||
out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false })
|
||||
}
|
||||
out.sort((a, b) => String(a.iso).localeCompare(String(b.iso)))
|
||||
if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true })
|
||||
return out
|
||||
})
|
||||
|
||||
function jobMatchesDateChips (j) {
|
||||
const df = dateFilter.value; if (!df.length) return true
|
||||
const ds = new Set(df); const t = todayISO()
|
||||
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
|
||||
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
|
||||
}
|
||||
|
||||
// Filtre : date d'abord (borne le périmètre) → compétence PAR ADRESSE (garde les jobs co-localisés d'un job filtré) → recherche texte.
|
||||
const filteredJobs = computed(() => {
|
||||
let arr = jobs.value
|
||||
if (dateFilter.value.length) arr = arr.filter(jobMatchesDateChips)
|
||||
const f = skillFilter.value
|
||||
if (f.length) {
|
||||
const set = new Set(f); const matchAddr = new Set()
|
||||
for (const j of arr) if (set.has(j.required_skill || 'autre')) { const k = jobLocKey(j); if (k) matchAddr.add(k) }
|
||||
arr = arr.filter(j => set.has(j.required_skill || 'autre') || matchAddr.has(jobLocKey(j)))
|
||||
}
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (q) arr = arr.filter(j => [j.subject, j.service_type, j.customer_name, j.address, j.required_skill].some(x => String(x || '').toLowerCase().includes(q)))
|
||||
return arr
|
||||
})
|
||||
|
||||
// Info d'adresse (périmètre du jour, AVANT filtre compétence) : nb de jobs + compétences distinctes → badges.
|
||||
const addrInfo = computed(() => {
|
||||
const base = dateFilter.value.length ? jobs.value.filter(jobMatchesDateChips) : jobs.value
|
||||
const m = {}
|
||||
for (const j of base) { const k = jobLocKey(j); if (!k) continue; const g = m[k] || (m[k] = { n: 0, skills: new Set() }); g.n++; if (j.required_skill) g.skills.add(j.required_skill) }
|
||||
return m
|
||||
})
|
||||
function jobAddrBadge (j) { const k = jobLocKey(j); const g = k && addrInfo.value[k]; if (!g || g.n < 2) return null; const skills = [...g.skills]; return { n: g.n, skills, multi: skills.length > 1 } }
|
||||
function jobAssocOnly (j) { const f = skillFilter.value; return f.length ? !new Set(f).has(j.required_skill || 'autre') : false }
|
||||
|
||||
// Modes de tri qui PRODUISENT des en-têtes de groupe (par variant) ; les autres = une seule liste à plat.
|
||||
const GROUPING = new Set(opts.groupingModes || (variant === 'mobile' ? ['sector'] : variant === 'docked' ? ['city', 'skill'] : ['group', 'skill', 'date', 'city', 'sector', 'priority']))
|
||||
const secOf = j => jobCity(j) || 'Sans secteur'
|
||||
const byDate = (a, b) => { const da = a.scheduled_date && a.scheduled_date !== 'Sans date' ? a.scheduled_date : '9999-99-99'; const db = b.scheduled_date && b.scheduled_date !== 'Sans date' ? b.scheduled_date : '9999-99-99'; return String(da).localeCompare(String(db)) }
|
||||
const dueRank = j => { const t = todayISO(); const sel = selectedDay(); const d = j.scheduled_date; if (d && d === sel) return 0; if (!d || d === 'Sans date') return 4; if (d < t) return 1; if (d === t) return 2; return 3 }
|
||||
|
||||
const groups = computed(() => {
|
||||
const list = filteredJobs.value; const mode = sort.value
|
||||
// Groupe parent-enfant (installation → activation…), ordonné par step_order.
|
||||
if (mode === 'group') {
|
||||
const g = {}; for (const j of list) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
|
||||
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
|
||||
}
|
||||
// Tris À PLAT (une seule liste, pas d'en-tête) : smart / prio / dist / dur.
|
||||
if (!GROUPING.has(mode)) {
|
||||
const cmp = mode === 'prio' ? (a, b) => pr(a) - pr(b) || byDate(a, b)
|
||||
: mode === 'dist' && distOf ? (a, b) => (distOf(a) ?? 9e9) - (distOf(b) ?? 9e9)
|
||||
: mode === 'dur' ? (a, b) => durMin(b) - durMin(a) || dueRank(a) - dueRank(b)
|
||||
: (a, b) => dueRank(a) - dueRank(b) || pr(a) - pr(b) || byDate(a, b) // 'smart'
|
||||
return [{ key: 'all', label: null, jobs: [...list].sort(cmp) }]
|
||||
}
|
||||
// Tris GROUPÉS : skill / date / city / sector / priority.
|
||||
const keyOf = j => mode === 'skill' ? (j.required_skill || 'Sans compétence')
|
||||
: (mode === 'city' || mode === 'sector') ? secOf(j)
|
||||
: mode === 'priority' ? (j.priority || 'low')
|
||||
: (j.scheduled_date || 'Sans date')
|
||||
const labelOf = k => mode === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k)
|
||||
: mode === 'date' ? '📅 ' + fmtDueLabel(k) : k
|
||||
const g = {}; for (const j of list) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
|
||||
const dir = sortDir.value === 'desc' ? -1 : 1
|
||||
const keys = Object.keys(g).sort((a, b) => (mode === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b)) * dir)
|
||||
return keys.map(k => {
|
||||
const gj = g[k]
|
||||
const mins = gj.reduce((s, j) => s + durMin(j), 0)
|
||||
return { key: k, label: labelOf(k), jobs: gj, n: gj.length, mins }
|
||||
})
|
||||
})
|
||||
|
||||
const allCollapsed = computed(() => { const gs = groups.value; return gs.length > 0 && gs.every(g => g.label != null && collapsed.value.has(g.key)) })
|
||||
|
||||
// Actions sur l'état de vue
|
||||
function toggleSkill (k) { const i = skillFilter.value.indexOf(k); if (i >= 0) skillFilter.value.splice(i, 1); else skillFilter.value.push(k) }
|
||||
function clearSkills () { skillFilter.value = [] }
|
||||
function toggleDate (iso) { const s = new Set(dateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); dateFilter.value = [...s] }
|
||||
function clearDates () { dateFilter.value = [] }
|
||||
function toggleCollapse (key) { const s = new Set(collapsed.value); s.has(key) ? s.delete(key) : s.add(key); collapsed.value = s }
|
||||
function toggleCollapseAll () { collapsed.value = allCollapsed.value ? new Set() : new Set(groups.value.filter(g => g.label != null).map(g => g.key)) }
|
||||
|
||||
return {
|
||||
variant,
|
||||
// état (writable)
|
||||
skillFilter, dateFilter, sort, sortDir, search, collapsed,
|
||||
// dérivés
|
||||
jobs, skillChips, dateChips, filteredJobs, groups, addrInfo, allCollapsed,
|
||||
// helpers
|
||||
jobMatchesDateChips, jobAddrBadge, jobAssocOnly,
|
||||
toggleSkill, clearSkills, toggleDate, clearDates, toggleCollapse, toggleCollapseAll,
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,8 @@ function hasGroup (groupName) {
|
|||
const userGroups = computed(() => permissions.value?.groups || [])
|
||||
const userName = computed(() => permissions.value?.name || '')
|
||||
const userEmail = computed(() => permissions.value?.email || '')
|
||||
// Profil employé ERPNext lié à la session (null si non lié) — voir resolveEmployeeForEmail côté hub.
|
||||
const userEmployee = computed(() => permissions.value?.employee || null)
|
||||
const isSuperuser = computed(() => permissions.value?.is_superuser || false)
|
||||
const isAdmin = computed(() => hasGroup('admin') || isSuperuser.value)
|
||||
const isLoaded = computed(() => permissions.value !== null)
|
||||
|
|
@ -103,6 +105,7 @@ export function usePermissions () {
|
|||
userGroups,
|
||||
userName,
|
||||
userEmail,
|
||||
userEmployee,
|
||||
isSuperuser,
|
||||
isAdmin,
|
||||
isLoaded,
|
||||
|
|
|
|||
51
apps/ops/src/composables/useSkillIcons.js
Normal file
51
apps/ops/src/composables/useSkillIcons.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// SOURCE UNIQUE des icônes de compétences (skill → icône). Extraite de PlanificationPage.vue 2026-07-08
|
||||
// pour être réutilisée (fiche client, dispo par motif, etc.) sans dupliquer la carte. Fonctions PURES.
|
||||
import { symOutlinedHeadsetMic } from '@quasar/extras/material-symbols-outlined'
|
||||
|
||||
// Camion-nacelle (bucket truck) pour « monteur » — Material Symbols n'a pas d'équivalent → SVG custom au format
|
||||
// q-icon Quasar (chemins séparés par && ; chaque chemin = d@@style). Aplats (carrosserie/roues/nacelle, roues
|
||||
// évidées en evenodd) + bras articulé en trait épais. Hérite couleur (currentColor) et taille. viewBox 512×416.
|
||||
export const BUCKET_TRUCK = 'M120,300 H440 V322 H120 Z M120,276 H310 V300 H120 Z M206,226 H280 V300 H206 Z M56,64 H160 V144 H56 Z@@fill:currentColor'
|
||||
+ '&&M300,300 V250 L322,222 H408 L438,260 V300 Z M330,252 H396 V284 H330 Z@@fill:currentColor;fill-rule:evenodd'
|
||||
+ '&&M144,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M170,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z M352,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M378,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z@@fill:currentColor;fill-rule:evenodd'
|
||||
+ '&&M243,250 L384,150 L120,104@@fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round'
|
||||
+ '|0 0 512 416'
|
||||
|
||||
// Icône de COMPÉTENCE (ligature Material OU SVG custom BUCKET_TRUCK). réparation → build, installation → construction,
|
||||
// sans-fil → cell_tower (PtP/tour, ≠ wifi maison), monteur → camion-nacelle, etc.
|
||||
export function skillIcon (sk) {
|
||||
const s = String(sk || '').toLowerCase()
|
||||
if (/install/.test(s)) return 'construction'
|
||||
if (/r[ée]par|d[ée]pann|bris/.test(s)) return 'build'
|
||||
if (/fibre|fusion|soud|épissure|epissure/.test(s)) return 'cable'
|
||||
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv'
|
||||
if (/t[ée]l[ée]phon|voip|\b3cx\b/.test(s)) return 'call'
|
||||
if (/r[ée]seau|net\s?admin|router|bgp|olt|acs/.test(s)) return 'dns'
|
||||
if (/\bwifi\b|wi-?fi|mesh/.test(s)) return 'wifi' // wifi MAISON (couverture interne) → symbole wifi simple
|
||||
if (/sans.?fil|wireless|\bfwa\b|\blte\b|radio|antenne|\btour\b|micro.?onde|point.?[àa].?point|\bptp\b|liaison/.test(s)) return 'cell_tower' // sans fil = PtP inter-bâtiments/tours → tour
|
||||
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return BUCKET_TRUCK // monteur / aérien → camion-nacelle
|
||||
if (/d[ée]sinstall|retrait|ramass|d[ée]mant/.test(s)) return 'delete_sweep'
|
||||
if (/info|ordinateur|\bpc\b|cam[ée]ra/.test(s)) return 'computer'
|
||||
if (/vente|sales|soumission/.test(s)) return 'sell'
|
||||
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
|
||||
return 'handyman' // visite générique (type non reconnu) : technicien avec outils
|
||||
}
|
||||
|
||||
// Variante pour q-icon : quelques symboles dédiés (TV/install/support) sinon délègue à skillIcon.
|
||||
export function skillSym (sk) {
|
||||
const s = String(sk || '').toLowerCase()
|
||||
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv' // AVANT « install » (« Install/Reparation Télé » contient « install »)
|
||||
if (/install/.test(s)) return 'router' // installation fibre = pose du routeur/ONT
|
||||
if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic
|
||||
return skillIcon(sk)
|
||||
}
|
||||
|
||||
// Icône pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures (pas de SVG custom).
|
||||
export function markerIcon (skill) {
|
||||
const s = String(skill || '').toLowerCase()
|
||||
if (/t[ée]l[ée]vis|\btv\b|iptv/.test(s)) return 'live_tv'
|
||||
if (/install/.test(s)) return 'router'
|
||||
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return 'local_shipping'
|
||||
const ic = skillIcon(skill)
|
||||
return (typeof ic === 'string' && /^[a-z0-9_]+$/.test(ic)) ? ic : 'build'
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
|
|||
const userSearchDone = ref(false)
|
||||
const selectedUser = ref(null)
|
||||
const savingGroups = ref(false)
|
||||
const savingName = ref(false)
|
||||
|
||||
// Groups tab
|
||||
const selectedGroup = ref(null)
|
||||
|
|
@ -77,6 +78,36 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
|
|||
}
|
||||
}
|
||||
|
||||
// Édite le NOM COMPLET Authentik (source unique affichée partout, y compris le
|
||||
// From sortant personnel). PUT /auth/users/{email}/name.
|
||||
async function saveUserName (user, newName) {
|
||||
const name = String(newName || '').trim()
|
||||
if (!name || name === user.name) return false
|
||||
savingName.value = true
|
||||
const previous = user.name
|
||||
try {
|
||||
const r = await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(user.email) + '/name', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
const data = await r.json()
|
||||
if (!r.ok) throw new Error(data?.error || ('HTTP ' + r.status))
|
||||
user.name = name
|
||||
// Reflète aussi dans la liste de résultats si l'utilisateur y figure.
|
||||
const inList = userResults.value.find(u => u.pk === user.pk)
|
||||
if (inList) inList.name = name
|
||||
Notify.create({ type: 'positive', message: `Nom mis à jour : ${name}`, timeout: 1500 })
|
||||
return true
|
||||
} catch (e) {
|
||||
user.name = previous
|
||||
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message })
|
||||
return false
|
||||
} finally {
|
||||
savingName.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function selectGroup (name) {
|
||||
if (selectedGroup.value === name) {
|
||||
selectedGroup.value = null
|
||||
|
|
@ -236,10 +267,10 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
|
|||
|
||||
return {
|
||||
userSearch, userResults, userSearchLoading, userSearchDone,
|
||||
selectedUser, savingGroups,
|
||||
selectedUser, savingGroups, savingName,
|
||||
selectedGroup, groupMembers, groupMembersLoading,
|
||||
addMemberSearch, memberSearchResults, memberSearchLoading,
|
||||
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
|
||||
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, saveUserName,
|
||||
selectGroup, loadGroupMembers, removeFromGroup,
|
||||
debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup,
|
||||
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
* Éditer ici pour ajuster les mots-clés / compétences — une seule source.
|
||||
*/
|
||||
export const DEPARTMENTS = [
|
||||
{ category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary', skills: [], dur: 1,
|
||||
{ category: 'Support', queue: 'Supports', icon: 'wifi', color: 'primary', skills: ['support'], dur: 1,
|
||||
re: /\b(wi-?fi|internet|lent[e]?|ralenti|panne|coup[ée]+|connexion|connecter?|signal|modem|routeur|red[ée]marr|d[ée]connect|d[ée]branch|intermittent|latence|ping|ne marche|ne fonctionne|pas d['e]internet|hors service|bug)\b/i },
|
||||
{ category: 'Facturation', queue: 'Facturations', icon: 'receipt_long', color: 'green-7', skills: [], dur: 1,
|
||||
re: /\b(factur|paiement|payer|pay[ée]|solde|rembours|pr[ée]l[èe]v|carte de cr[ée]dit|montant|frais|cr[ée]dit|impay[ée]|retard de paiement|re[çc]u|virement|interac|trop per[çc]u)\b/i },
|
||||
|
|
|
|||
|
|
@ -51,10 +51,15 @@
|
|||
<q-item-section v-if="!collapsed"><q-item-label>Réduire</q-item-label></q-item-section>
|
||||
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Agrandir le menu</q-tooltip>
|
||||
</q-item>
|
||||
<q-item dense clickable @click="profileOpen = true" :class="{ 'justify-center': collapsed }">
|
||||
<q-item-section avatar><UserAvatar :email="userEmail" :name="userName" :size="collapsed ? 22 : 24" /></q-item-section>
|
||||
<q-item-section v-if="!collapsed"><q-item-label>{{ userName || auth.user || 'User' }}</q-item-label><q-item-label caption>Mon profil</q-item-label></q-item-section>
|
||||
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Mon profil</q-tooltip>
|
||||
</q-item>
|
||||
<q-item dense clickable @click="auth.doLogout()" :class="{ 'justify-center': collapsed }">
|
||||
<q-item-section avatar><component :is="icons.LogOut" :size="16" /></q-item-section>
|
||||
<q-item-section v-if="!collapsed"><q-item-label>{{ userName || auth.user || 'User' }}</q-item-label></q-item-section>
|
||||
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ userName || auth.user || 'Déconnexion' }}</q-tooltip>
|
||||
<q-item-section v-if="!collapsed"><q-item-label>Déconnexion</q-item-label></q-item-section>
|
||||
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Déconnexion</q-tooltip>
|
||||
</q-item>
|
||||
</div>
|
||||
</q-drawer>
|
||||
|
|
@ -67,6 +72,9 @@
|
|||
<q-space />
|
||||
<NotificationBell v-if="can('view_clients')" dark />
|
||||
<q-btn v-if="!isDispatch" flat round dense icon="search" color="white" @click="mobileSearchOpen = !mobileSearchOpen" />
|
||||
<q-btn flat round dense @click="profileOpen = true">
|
||||
<UserAvatar :email="userEmail" :name="userName" :size="26" />
|
||||
</q-btn>
|
||||
</q-toolbar>
|
||||
<div v-if="mobileSearchOpen && !isDispatch" class="q-px-sm q-pb-sm" style="background:var(--ops-sidebar-bg)">
|
||||
<q-input v-model="searchQuery" placeholder="Rechercher client, adresse..." dense outlined dark autofocus class="ops-search-dark"
|
||||
|
|
@ -122,6 +130,9 @@
|
|||
</div>
|
||||
</div>
|
||||
<NotificationBell v-if="can('view_clients')" class="q-ml-sm" />
|
||||
<q-btn flat round dense class="q-ml-sm" @click="profileOpen = true">
|
||||
<UserAvatar :email="userEmail" :name="userName" :size="30" tooltip />
|
||||
</q-btn>
|
||||
</div>
|
||||
<router-view />
|
||||
</q-page-container>
|
||||
|
|
@ -154,8 +165,12 @@
|
|||
</div>
|
||||
</q-page-sticky>
|
||||
|
||||
<!-- Commande dictée / langage naturel → orchestre plusieurs actions -->
|
||||
<!-- Commande dictée / langage naturel → orchestre plusieurs actions (comms : tickets/SMS/courriel) -->
|
||||
<OrchestratorDialog v-if="can('view_clients')" v-model="orchestratorOpen" />
|
||||
<!-- ⌘K langage naturel → copilote STAFF dispatch/planification (function-calling ; plan → confirmer → exécuter).
|
||||
MÊME composant réutilisé, branché sur le endpoint STAFF avec l'identité de l'agent (permissions serveur). -->
|
||||
<OrchestratorDialog v-if="can('view_clients')" v-model="staffCmdOpen" :initial-text="staffCmdText"
|
||||
title="Assistant Ops" plan-endpoint="/staff-agent" run-endpoint="/staff-agent/run" send-identity />
|
||||
<!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) — lecture -->
|
||||
<ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" />
|
||||
<!-- Tampon d'envoi : courriels multi-destinataires retenus + compte à rebours + annuler (anti-spam) -->
|
||||
|
|
@ -164,8 +179,10 @@
|
|||
<!-- 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')" />
|
||||
<!-- Palette de commandes globale (Cmd/Ctrl+K) : actions + pages + actions de page + clients/équipe + langage naturel -->
|
||||
<CommandPalette v-if="can('view_clients')" @nl="onNlCommand" />
|
||||
|
||||
<UserProfileDialog v-model="profileOpen" />
|
||||
|
||||
</q-layout>
|
||||
</template>
|
||||
|
|
@ -189,6 +206,8 @@ const ConversationPanel = defineAsyncComponent(() => import('src/components/shar
|
|||
const PhoneModal = defineAsyncComponent(() => import('src/components/customer/PhoneModal.vue'))
|
||||
import { useSoftphone } from 'src/composables/useSoftphone'
|
||||
import NotificationBell from 'src/components/shared/NotificationBell.vue'
|
||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||
import UserProfileDialog from 'src/components/shared/UserProfileDialog.vue'
|
||||
import { useConversations } from 'src/composables/useConversations'
|
||||
import { useCreateSignal } from 'src/composables/useCreateSignal'
|
||||
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
|
||||
|
|
@ -213,6 +232,11 @@ const orchestratorOpen = ref(false)
|
|||
const serviceStatusOpen = ref(false)
|
||||
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
|
||||
|
||||
// ⌘K → langage naturel : ouvre le copilote STAFF (dispatch/planif) pré-rempli avec la requête tapée.
|
||||
const staffCmdOpen = ref(false)
|
||||
const staffCmdText = ref('')
|
||||
function onNlCommand (text) { staffCmdText.value = text || ''; staffCmdOpen.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) {
|
||||
|
|
@ -222,7 +246,8 @@ onMounted(() => window.addEventListener('keydown', onGlobalKey))
|
|||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKey))
|
||||
|
||||
const auth = useAuthStore()
|
||||
const { can, isLoaded, userName } = usePermissions()
|
||||
const { can, isLoaded, userName, userEmail } = usePermissions()
|
||||
const profileOpen = ref(false)
|
||||
|
||||
// Filter nav items based on user capabilities
|
||||
const navItems = computed(() =>
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@
|
|||
<q-td :props="props">
|
||||
<span v-if="props.row.customer_id">
|
||||
<q-icon name="person" size="14px" class="q-mr-xs" />
|
||||
<a :href="`/#/clients/${props.row.customer_id}`" target="_blank" style="color:var(--q-primary)">
|
||||
<a :href="`#/clients/${props.row.customer_id}`" target="_blank" style="color:var(--q-primary)">
|
||||
{{ props.row.customer_name || props.row.customer_id }}
|
||||
</a>
|
||||
<q-chip dense size="xs" outline class="q-ml-xs">{{ props.row.match_method }}</q-chip>
|
||||
|
|
|
|||
|
|
@ -9,19 +9,6 @@
|
|||
<div class="t-caption" style="color:var(--tg-700);font-weight:700">{{ job?.name || '…' }}</div>
|
||||
<div class="topbar-subject">{{ job?.subject || 'Job' }}</div>
|
||||
</div>
|
||||
<button class="topbar-btn" @click="menuOpen = !menuOpen" aria-label="Menu">
|
||||
<span class="material-icons">more_vert</span>
|
||||
</button>
|
||||
<!-- Popover menu -->
|
||||
<transition name="fade">
|
||||
<div v-if="menuOpen" class="topbar-menu" @click.self="menuOpen = false">
|
||||
<div class="topbar-menu-card">
|
||||
<button class="topbar-menu-item" @click="openInErp(); menuOpen = false">
|
||||
<span class="material-icons">open_in_new</span> Ouvrir dans ERPNext
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</header>
|
||||
|
||||
<!-- Loading state -->
|
||||
|
|
@ -267,7 +254,6 @@ import { ref, computed, onMounted } from 'vue'
|
|||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Notify } from 'quasar'
|
||||
import { getDoc, listDocs, createDoc, updateDoc } from 'src/api/erp'
|
||||
import { BASE_URL } from 'src/config/erpnext'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -276,7 +262,6 @@ const router = useRouter()
|
|||
const jobName = computed(() => route.params.name)
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const menuOpen = ref(false)
|
||||
const job = ref(null)
|
||||
const locationDetail = ref(null)
|
||||
const equipment = ref([])
|
||||
|
|
@ -434,10 +419,6 @@ function openGps () {
|
|||
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(addr)}`, '_blank')
|
||||
}
|
||||
|
||||
function openInErp () {
|
||||
window.open(`${BASE_URL}/app/dispatch-job/${job.value.name}`, '_blank')
|
||||
}
|
||||
|
||||
function goScan () {
|
||||
router.push({
|
||||
name: 'tech-scan',
|
||||
|
|
|
|||
|
|
@ -24,18 +24,13 @@
|
|||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<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>
|
||||
<q-btn dense flat size="sm" no-caps color="grey-7" icon="tune" label="Personnaliser" @click="openCustomize">
|
||||
<q-tooltip>Réorganiser ou masquer les modules de la fiche — enregistré pour vous</q-tooltip>
|
||||
<q-btn dense flat size="sm" no-caps color="teal-8" icon="event_available" label="Trouver un créneau" @click="openAvailReason">
|
||||
<q-tooltip>Prendre rendez-vous : motif → disponibilité ajustée à la compétence requise (icônes de compétences · « sans-fil » ajouté si le client est sans-fil) → réserver un créneau</q-tooltip>
|
||||
</q-btn>
|
||||
<OverflowMenu :items="[
|
||||
{ icon: 'card_giftcard', label: 'Récompense', caption: 'Carte-cadeau : lien · éditer · envoyer', action: () => { rewardOpen = true } },
|
||||
{ icon: 'tune', label: 'Personnaliser la fiche', caption: 'Réorganiser ou masquer les modules', action: openCustomize },
|
||||
]" />
|
||||
</div>
|
||||
</template>
|
||||
<template #contact><ContactCard :customer="customer" /></template>
|
||||
|
|
@ -344,7 +339,7 @@
|
|||
</div>
|
||||
<div v-if="!commsDiscussions.length" class="text-center text-grey-6 q-pa-md text-caption">Aucune conversation (courriel / SMS) liée à ce client.</div>
|
||||
<q-list v-else separator style="border-radius:8px;overflow:hidden">
|
||||
<q-item v-for="d in commsDiscussions" :key="d.id" clickable @click="openComms(d)">
|
||||
<q-item v-for="d in (msgOpen ? commsDiscussions : commsDiscussions.slice(0, 1))" :key="d.id" clickable @click="openComms(d)">
|
||||
<q-item-section avatar><q-icon :name="d.channel === 'email' ? 'mail' : 'sms'" :color="d.channel === 'email' ? 'red-5' : 'teal-6'" size="20px" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1">{{ commsTitle(d) }}</q-item-label>
|
||||
|
|
@ -356,6 +351,9 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div v-if="commsDiscussions.length > 1" class="text-center q-pt-xs c360-more" style="cursor:pointer;color:#6366f1;font-size:0.8rem;user-select:none" @click="msgOpen = !msgOpen">
|
||||
{{ msgOpen ? '▲ Réduire' : '▼ Voir ' + (commsDiscussions.length - 1) + ' de plus' }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Journal d'activité : tickets, interventions, appels, système. Les courriels/SMS sont dans « Messagerie » ci-dessus (pas re-dupliqués ici). -->
|
||||
<div class="ops-card q-pa-sm q-mb-md" style="border-radius:12px">
|
||||
|
|
@ -366,7 +364,7 @@
|
|||
</div>
|
||||
<div v-if="!activityLog.length" class="text-center text-grey-6 q-pa-md text-caption">Aucune activité récente.</div>
|
||||
<q-list v-else separator style="border-radius:8px;overflow:hidden">
|
||||
<q-item v-for="r in (recentOpen ? activityLog : activityLog.slice(0, 5))" :key="r.key" clickable @click="r.act()">
|
||||
<q-item v-for="r in (recentOpen ? activityLog : activityLog.slice(0, 1))" :key="r.key" clickable @click="r.act()">
|
||||
<q-item-section avatar><q-icon :name="r.icon" :color="r.color" size="20px" /></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label lines="1">{{ r.label }}</q-item-label>
|
||||
|
|
@ -375,8 +373,8 @@
|
|||
<q-item-section side><q-item-label caption>{{ formatDate(r.date) }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
<div v-if="activityLog.length > 5" class="text-center q-pt-xs c360-more" style="cursor:pointer;color:#6366f1;font-size:0.8rem;user-select:none" @click="recentOpen = !recentOpen">
|
||||
{{ recentOpen ? '▲ Réduire' : '▼ Voir ' + (activityLog.length - 5) + ' de plus' }}
|
||||
<div v-if="activityLog.length > 1" class="text-center q-pt-xs c360-more" style="cursor:pointer;color:#6366f1;font-size:0.8rem;user-select:none" @click="recentOpen = !recentOpen">
|
||||
{{ recentOpen ? '▲ Réduire' : '▼ Voir ' + (activityLog.length - 1) + ' de plus' }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Notes internes (secondaire) — Comment ERPNext. -->
|
||||
|
|
@ -391,7 +389,7 @@
|
|||
<q-btn dense unelevated color="warning" icon="add" :disable="!newNote.trim()" :loading="addingNote" @click="addNote"><q-tooltip>Enregistrer la note (Ctrl/⌘+Entrée)</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div v-if="!sortedComments.length" class="text-grey-6 text-caption q-pa-xs">Aucune note</div>
|
||||
<div v-for="c in sortedComments" :key="c.name" class="q-pa-xs q-mb-xs" style="border-left:3px solid #fbbf24;background:#fffbeb;border-radius:6px">
|
||||
<div v-for="c in (notesOpen ? sortedComments : sortedComments.slice(0, 1))" :key="c.name" class="q-pa-xs q-mb-xs" style="border-left:3px solid #fbbf24;background:#fffbeb;border-radius:6px">
|
||||
<div class="row items-center no-wrap">
|
||||
<span class="text-caption text-weight-medium text-warning">{{ noteAuthorName(c.comment_by || c.owner) }}</span>
|
||||
<q-space />
|
||||
|
|
@ -400,6 +398,9 @@
|
|||
</div>
|
||||
<div style="white-space:pre-wrap;font-size:0.85rem;color:#334155">{{ c.content }}</div>
|
||||
</div>
|
||||
<div v-if="sortedComments.length > 1" class="text-center q-pt-xs c360-more" style="cursor:pointer;color:#6366f1;font-size:0.8rem;user-select:none" @click="notesOpen = !notesOpen">
|
||||
{{ notesOpen ? '▲ Réduire' : '▼ Voir ' + (sortedComments.length - 1) + ' de plus' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -598,7 +599,7 @@
|
|||
: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" />
|
||||
:address="findSlotAddr" :connection-type="primaryLocation()?.connection_type || ''" @book="onAvailBook" />
|
||||
<UnifiedCreateModal v-model="createJobOpen" mode="work-order"
|
||||
:context="createJobCtx" :locations="locations" @created="onJobCreated" />
|
||||
|
||||
|
|
@ -612,6 +613,7 @@
|
|||
:doc-name="modalDocName" :title="modalTitle" :doc="modalDoc"
|
||||
:comments="modalComments" :comms="modalComms" :files="modalFiles"
|
||||
:doc-fields="modalDocFields" :dispatch-jobs="modalDispatchJobs"
|
||||
:hide-customer-link="true"
|
||||
@navigate="(dt, name, t) => openModal(dt, name, t)"
|
||||
@open-pdf="openPdf" @save-field="saveSubField"
|
||||
@toggle-recurring="toggleRecurringModal" @dispatch-created="onDispatchCreated"
|
||||
|
|
@ -677,7 +679,33 @@
|
|||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat no-caps label="Annuler" v-close-popup />
|
||||
<q-btn unelevated no-caps color="primary" icon="check" label="Enregistrer le paiement" :loading="payDlg.submitting" :disable="!(payDlg.amount > 0) || payDlg.loading || !!payDlg.error" @click="payInvoiceConfirm" />
|
||||
<q-btn unelevated no-caps color="primary" icon="check" label="Enregistrer le paiement" :loading="payDlg.submitting" :disable="!(payDlg.amount > 0) || payDlg.loading || !!payDlg.error || !payDlg.preview" @click="payInvoiceConfirm" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Paiement à l'avance / non alloué (SANS facture) — natif, remplace l'ancien lien desk « Payment Entry ». Aperçu puis confirmation. -->
|
||||
<q-dialog v-model="onAcctDlg.open">
|
||||
<q-card style="min-width:360px;max-width:480px">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<q-icon name="savings" color="primary" size="22px" class="q-mr-sm" />
|
||||
<div class="text-h6">Enregistrer un paiement</div>
|
||||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Paiement général de <b>{{ customer?.customer_name }}</b> (à l'avance / non alloué à une facture précise — crédit sur le compte client).</div>
|
||||
<q-input dense outlined type="number" step="0.01" v-model.number="onAcctDlg.amount" label="Montant reçu" prefix="$" @blur="onAcctPreview" />
|
||||
<q-select dense outlined v-model="onAcctDlg.mode" :options="['Cheque', 'Cash']" label="Mode de paiement" class="q-mt-sm" @update:model-value="onAcctPreview" />
|
||||
<q-input dense outlined v-model="onAcctDlg.referenceNo" label="Référence (n° chèque, transaction…)" class="q-mt-sm" />
|
||||
<div class="q-mt-sm" style="min-height:20px">
|
||||
<q-spinner v-if="onAcctDlg.loading" color="primary" size="18px" />
|
||||
<div v-else-if="onAcctDlg.preview" class="text-caption text-grey-7"><q-icon name="account_balance" size="13px" /> Comptabilisé via ERPNext — dépôt <b>{{ onAcctDlg.preview.paid_to }}</b> ; crédit non alloué sur le compte client.</div>
|
||||
</div>
|
||||
<div v-if="onAcctDlg.error" class="text-negative text-caption q-mt-sm">{{ onAcctDlg.error }}</div>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat no-caps label="Annuler" v-close-popup />
|
||||
<q-btn unelevated no-caps color="primary" icon="check" label="Enregistrer" :loading="onAcctDlg.submitting" :disable="!(onAcctDlg.amount > 0) || onAcctDlg.loading || !!onAcctDlg.error || !onAcctDlg.preview" @click="onAcctConfirm" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
|
@ -691,7 +719,7 @@ import { Notify, useQuasar } from 'quasar'
|
|||
import draggable from 'vuedraggable'
|
||||
import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
|
||||
import { authFetch } from 'src/api/auth'
|
||||
import { BASE_URL, ERP_DESK_URL } from 'src/config/erpnext'
|
||||
import { BASE_URL } from 'src/config/erpnext'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
import { formatDate, formatDateTime, formatMoney, noteAuthorName } from 'src/composables/useFormatters'
|
||||
import { invStatusClass } from 'src/composables/useStatusClasses'
|
||||
|
|
@ -712,6 +740,7 @@ 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 OverflowMenu from 'src/components/shared/OverflowMenu.vue' // ⋮ regroupe Récompense + Personnaliser (déclutter en-tête)
|
||||
import { DEPARTMENTS } from 'src/config/departments'
|
||||
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
||||
import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.vue'
|
||||
|
|
@ -1287,11 +1316,33 @@ function commsSnippet (d) {
|
|||
const openTicketCount = computed(() => tickets.value.filter(t => t.status === 'Open').length)
|
||||
// Affiche 5 tickets par défaut ; « Voir tous » révèle le reste (déjà chargés) ou les recharge tous.
|
||||
const visibleTickets = computed(() => ticketsExpanded.value ? tickets.value : tickets.value.slice(0, 5))
|
||||
// Inscrire un paiement reçu : ouvre le formulaire Payment Entry d'ERPNext pré-rempli (accounts/validation gérés par ERPNext — pas de doc financier fabriqué à la main).
|
||||
// Inscrire un paiement à l'avance / non alloué (SANS facture) — dialogue NATIF (hub /payments/record-on-account),
|
||||
// remplace l'ancien lien vers le formulaire Payment Entry d'ERPNext desk.
|
||||
const onAcctDlg = reactive({ open: false, amount: 0, mode: 'Cheque', referenceNo: '', loading: false, submitting: false, preview: null, error: '' })
|
||||
function recordPayment () {
|
||||
if (!customer.value) return
|
||||
const url = ERP_DESK_URL + '/app/payment-entry/new?payment_type=Receive&party_type=Customer&party=' + encodeURIComponent(customer.value.name)
|
||||
window.open(url, '_blank', 'noopener')
|
||||
onAcctDlg.amount = 0; onAcctDlg.mode = 'Cheque'; onAcctDlg.referenceNo = ''; onAcctDlg.preview = null; onAcctDlg.error = ''
|
||||
onAcctDlg.open = true
|
||||
}
|
||||
async function onAcctReq (preview) {
|
||||
return fetch(`${HUB_URL}/payments/record-on-account`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ customer: customer.value.name, amount: Number(onAcctDlg.amount) || undefined, mode_of_payment: onAcctDlg.mode, reference_no: onAcctDlg.referenceNo || undefined, preview: !!preview }),
|
||||
}).then(r => r.json().then(d => ({ ok: r.ok, d })))
|
||||
}
|
||||
async function onAcctPreview () {
|
||||
if (!(onAcctDlg.amount > 0)) { onAcctDlg.preview = null; return }
|
||||
onAcctDlg.loading = true; onAcctDlg.error = ''
|
||||
try { const { ok, d } = await onAcctReq(true); if (!ok || d.error) { onAcctDlg.error = d.error || 'Erreur'; onAcctDlg.preview = null } else onAcctDlg.preview = d }
|
||||
catch (e) { onAcctDlg.error = e.message } finally { onAcctDlg.loading = false }
|
||||
}
|
||||
async function onAcctConfirm () {
|
||||
onAcctDlg.submitting = true; onAcctDlg.error = ''
|
||||
try {
|
||||
const { ok, d } = await onAcctReq(false)
|
||||
if (!ok || d.error) { onAcctDlg.error = d.error || 'Échec de l\'enregistrement' }
|
||||
else { $q.notify({ type: 'positive', message: `Paiement de ${formatMoney(d.paid_amount)} enregistré (${d.name})` }); onAcctDlg.open = false; loadCustomer(props.id) }
|
||||
} catch (e) { onAcctDlg.error = e.message } finally { onAcctDlg.submitting = false }
|
||||
}
|
||||
|
||||
// « Enregistrer un paiement » par facture (modèle Odoo, sans quitter l'app) : aperçu ERPNext → confirmation → écriture.
|
||||
|
|
@ -1332,7 +1383,9 @@ const kpi360 = computed(() => [
|
|||
{ key: 'subs', icon: 'router', color: 'blue-6', label: 'Abonnements actifs', value: activeSubsCount.value },
|
||||
{ key: 'balance', icon: 'account_balance_wallet', color: totalOutstanding.value > 0 ? 'red-6' : 'green-7', label: 'Solde', value: formatMoney(totalOutstanding.value) },
|
||||
])
|
||||
const recentOpen = ref(false) // Journal d'activité (droite) : 5 par défaut, « Voir N de plus / Réduire »
|
||||
const recentOpen = ref(false) // Journal d'activité (droite) : dernier seulement, « Voir N de plus / Réduire »
|
||||
const msgOpen = ref(false) // Messagerie (droite) : dernière conversation seulement
|
||||
const notesOpen = ref(false) // Notes internes (droite) : dernière note seulement
|
||||
// Journal = tickets + interventions + appels/comms loggées (Communication ERPNext). Les courriels/SMS vivent dans le panneau « Messagerie » → PAS re-dupliqués ici.
|
||||
const activityLog = computed(() => {
|
||||
const items = []
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
<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>
|
||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">Planificateur <HelpHint title="Planificateur automatique" text="Quand il est actif, la répartition automatique peut assigner et replanifier les interventions selon les compétences, la disponibilité et la distance. Désactivez-le pour garder la main entièrement manuelle sur le dispatch." /></div>
|
||||
<div class="row items-center q-gutter-md">
|
||||
<q-chip :color="schedulerEnabled ? 'positive' : 'negative'" text-color="white" icon="schedule">
|
||||
{{ schedulerEnabled ? 'Actif' : 'Désactivé' }}
|
||||
|
|
@ -71,7 +71,14 @@
|
|||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="ops-card">
|
||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">Facturation récurrente</div>
|
||||
<div class="text-subtitle1 text-weight-bold q-mb-sm">Facturation récurrente <HelpHint title="Cycle de facturation (3 étapes)">
|
||||
<ul>
|
||||
<li><b>1. Générer</b> — crée les factures brouillon des abonnements actifs.</li>
|
||||
<li><b>2. Soumettre</b> — valide les brouillons (factures officielles).</li>
|
||||
<li><b>3. PPA</b> — prélève les cartes enregistrées.</li>
|
||||
</ul>
|
||||
<b>⚠ Le PPA prélève immédiatement</b> : à lancer une seule fois par cycle (un double lancement peut charger deux fois).
|
||||
</HelpHint></div>
|
||||
|
||||
<!-- Step indicators -->
|
||||
<div class="row items-center q-gutter-x-sm q-mb-sm">
|
||||
|
|
@ -207,6 +214,7 @@ import { getCapacity } from 'src/api/roster'
|
|||
import { BASE_URL } from 'src/config/erpnext'
|
||||
import { HUB_SSE_URL } from 'src/config/dispatch'
|
||||
import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
|
||||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||||
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
|
||||
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
|
||||
import DisclosureSection from 'src/components/shared/DisclosureSection.vue'
|
||||
|
|
|
|||
|
|
@ -22,15 +22,64 @@
|
|||
flat dense class="ops-table"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 20 }"
|
||||
/>
|
||||
>
|
||||
<template #body-cell-employee_name="props">
|
||||
<q-td :props="props">
|
||||
<div class="row items-center no-wrap">
|
||||
<UserAvatar :email="props.row.company_email" :name="props.row.employee_name" :size="28" class="q-mr-sm" />
|
||||
<span>{{ props.row.employee_name }}</span>
|
||||
</div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-login="props">
|
||||
<q-td :props="props" class="text-center">
|
||||
<q-icon v-if="props.row.user_id" name="link" color="green-6" size="18px">
|
||||
<q-tooltip>Compte de connexion lié : {{ props.row.user_id }}</q-tooltip>
|
||||
</q-icon>
|
||||
<q-icon v-else name="link_off" color="grey-4" size="18px">
|
||||
<q-tooltip>Aucun compte de connexion lié</q-tooltip>
|
||||
</q-icon>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props" class="text-right">
|
||||
<q-btn flat dense round icon="edit" color="primary" size="sm" @click="editEmployee(props.row)">
|
||||
<q-tooltip>Modifier le profil employé</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense round icon="admin_panel_settings" color="blue-grey-6" size="sm"
|
||||
:disable="!(props.row.user_id || props.row.company_email)" @click="openPermissions(props.row)">
|
||||
<q-tooltip>{{ (props.row.user_id || props.row.company_email) ? 'Permissions & compte (Administration)' : 'Aucun compte lié' }}</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<EmployeeEditDialog v-model="empDialogOpen" :employee-name="empDialogName" @saved="reload" />
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listDocs } from 'src/api/erp'
|
||||
import DataTable from 'src/components/shared/DataTable.vue'
|
||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// Édition du profil employé (fiche ERPNext) — dialogue partagé.
|
||||
const empDialogOpen = ref(false)
|
||||
const empDialogName = ref('')
|
||||
function editEmployee (row) { empDialogName.value = row.name; empDialogOpen.value = true }
|
||||
|
||||
// Permissions & compte de connexion → Administration (Authentik), jointure sur le courriel du login.
|
||||
function openPermissions (row) {
|
||||
const login = row.user_id || row.company_email
|
||||
if (!login) return
|
||||
router.push({ path: '/settings', query: { user: login } })
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
const techs = ref([])
|
||||
|
|
@ -48,15 +97,17 @@ const columns = [
|
|||
{ name: 'cell_number', label: 'Téléphone', field: 'cell_number', align: 'left' },
|
||||
{ name: 'office_extension', label: 'Ext.', field: 'office_extension', align: 'center' },
|
||||
{ name: 'company_email', label: 'Courriel', field: 'company_email', align: 'left' },
|
||||
{ name: 'login', label: 'Compte', field: 'user_id', align: 'center' },
|
||||
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
|
||||
{ name: 'actions', label: '', field: 'actions', align: 'right' },
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
async function reload () {
|
||||
loading.value = true
|
||||
try {
|
||||
techs.value = await listDocs('Employee', {
|
||||
filters: { status: 'Active' },
|
||||
fields: ['name', 'employee_name', 'designation', 'department', 'status', 'cell_number', 'company_email', 'office_extension'],
|
||||
fields: ['name', 'employee_name', 'designation', 'department', 'status', 'cell_number', 'company_email', 'office_extension', 'user_id'],
|
||||
limit: 50,
|
||||
orderBy: 'employee_name asc',
|
||||
})
|
||||
|
|
@ -66,5 +117,7 @@ onMounted(async () => {
|
|||
techs.value = []
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(reload)
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
</div>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">{{ r.name || r.customerName || r.customer || r.email || 'Client' }}</q-item-label>
|
||||
<q-item-label class="text-weight-medium"><router-link v-if="r.customer" :to="'/clients/' + encodeURIComponent(r.customer)" class="erp-link">{{ r.name || r.customerName || r.customer || r.email || 'Client' }}</router-link><template v-else>{{ r.name || r.customerName || r.email || 'Client' }}</template></q-item-label>
|
||||
<q-item-label v-if="r.comment" class="q-mt-xs" style="white-space:pre-wrap;color:#334155">« {{ r.comment }} »</q-item-label>
|
||||
<q-item-label v-else caption class="text-grey-5">Aucun commentaire</q-item-label>
|
||||
</q-item-section>
|
||||
|
|
|
|||
|
|
@ -156,6 +156,9 @@
|
|||
<q-input v-if="lg === 'en'" v-model="form.en.name" dense outlined label="Name" />
|
||||
<q-input v-model="form[lg].tagline" dense outlined autogrow label="Accroche" />
|
||||
<q-input v-model="form[lg].when" dense outlined :label="lg === 'fr' ? 'Quand (texte)' : 'When (text)'" hint="ex. 1ᵉʳ août, de 10 h à 15 h" />
|
||||
<q-input v-model="form[lg].address" dense outlined :label="lg === 'fr' ? 'Adresse (texte, optionnel)' : 'Address (text, optional)'" hint="ex. À nos bureaux : 1867 chemin de la rivière, Ste-Clotilde">
|
||||
<template #prepend><q-icon name="place" /></template>
|
||||
</q-input>
|
||||
<q-input v-model="form[lg].body" dense outlined type="textarea" autogrow :label="lg === 'fr' ? 'Texte d\'invitation' : 'Invitation text'" />
|
||||
|
||||
<div class="text-weight-medium text-grey-8 q-mt-sm">Programme</div>
|
||||
|
|
@ -371,15 +374,18 @@
|
|||
<q-btn unelevated no-caps color="primary" icon="playlist_add" label="Ajouter le CSV à la liste" :disable="!audCsvText" :loading="listBusy" @click="addCsvToList" />
|
||||
</div>
|
||||
|
||||
<q-banner dense rounded class="bg-grey-2 text-grey-8">
|
||||
<template #avatar><q-icon name="lock" color="grey-7" /></template>
|
||||
L'envoi réel (création de la campagne + envoi suivi) est la dernière étape à activer — rien n'est envoyé aux clients ici.
|
||||
<q-separator class="q-my-sm" />
|
||||
<q-banner dense rounded class="bg-blue-1 text-blue-10">
|
||||
<template #avatar><q-icon name="insights" color="blue-8" /></template>
|
||||
L'envoi crée une campagne : suivi ouvertures/clics (Mailjet), statut par destinataire et rapport dans <a class="text-primary cursor-pointer text-weight-medium" @click="goCampaigns">Campagnes</a>.
|
||||
</q-banner>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat no-caps label="Fermer" v-close-popup />
|
||||
<q-btn v-if="sendMode === 'test'" unelevated no-caps color="primary" icon="send" label="Envoyer un test" :loading="sending" @click="sendTestNow" />
|
||||
<q-btn v-else unelevated no-caps color="negative" icon="send" :disable="!mainList.count || sendingMass" :loading="sendingMass"
|
||||
:label="mainList.count ? `Envoyer à ${mainList.count} destinataire(s)` : 'Liste vide'" @click="confirmMassSend" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
|
@ -539,7 +545,7 @@ const uploadingAtt = ref(false)
|
|||
function attLangLabel (l) { return l === 'fr' ? 'FR' : l === 'en' ? 'EN' : 'Les deux' }
|
||||
function attLangColor (l) { return l === 'fr' ? 'blue-7' : l === 'en' ? 'deep-orange-7' : 'grey-6' }
|
||||
|
||||
const emptyLang = () => ({ name: '', tagline: '', when: '', body: '', program: [], program_line: '', limited: '', closing: '', notes: [] })
|
||||
const emptyLang = () => ({ name: '', tagline: '', when: '', address: '', body: '', program: [], program_line: '', limited: '', closing: '', notes: [] })
|
||||
const emptyForm = () => ({ id: '', active: true, date_iso: '', badge_top: '', badge_bottom: '', capacity: 0, email_template: '', fr: emptyLang(), en: emptyLang() })
|
||||
const form = ref(emptyForm())
|
||||
|
||||
|
|
@ -578,7 +584,7 @@ async function openEdit () {
|
|||
for (const lg of ['fr', 'en']) {
|
||||
const src = c[lg] || {}
|
||||
f[lg] = {
|
||||
name: src.name || '', tagline: src.tagline || '', when: src.when || '', body: src.body || '',
|
||||
name: src.name || '', tagline: src.tagline || '', when: src.when || '', address: src.address || '', body: src.body || '',
|
||||
program: Array.isArray(src.program) ? src.program.map(p => ({ icon: p.icon || '', label: p.label || '', sub: p.sub || '' })) : [],
|
||||
program_line: src.program_line || '', limited: src.limited || '', closing: src.closing || '',
|
||||
notes: Array.isArray(src.notes) ? [...src.notes] : [],
|
||||
|
|
@ -602,7 +608,7 @@ async function openInvitePreview () { invitePreviewOpen.value = true; await load
|
|||
|
||||
function cleanLang (c) {
|
||||
return {
|
||||
name: (c.name || '').trim(), tagline: (c.tagline || '').trim(), when: (c.when || '').trim(), body: (c.body || '').trim(),
|
||||
name: (c.name || '').trim(), tagline: (c.tagline || '').trim(), when: (c.when || '').trim(), address: (c.address || '').trim(), body: (c.body || '').trim(),
|
||||
program: (c.program || []).filter(p => (p.label || '').trim() || (p.icon || '').trim()),
|
||||
program_line: (c.program_line || '').trim(), limited: (c.limited || '').trim(), closing: (c.closing || '').trim(),
|
||||
notes: (c.notes || []).map(n => (n || '').trim()).filter(Boolean),
|
||||
|
|
@ -777,6 +783,29 @@ function clearList () {
|
|||
catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { listBusy.value = false }
|
||||
})
|
||||
}
|
||||
function goCampaigns () { window.location.hash = '#/campaigns' }
|
||||
const sendingMass = ref(false)
|
||||
function confirmMassSend () {
|
||||
const n = mainList.value.count
|
||||
if (!n) return
|
||||
const chan = sendChannel.value === 'gmail' ? 'Gmail' : 'Mailjet'
|
||||
$q.dialog({
|
||||
title: "Envoyer l'invitation",
|
||||
message: `Envoyer un vrai courriel d'invitation à <b>${n}</b> destinataire(s) via <b>${chan}</b> ?<br>Chacun reçoit un lien RSVP personnel. Cette action est irréversible.`,
|
||||
html: true,
|
||||
cancel: { label: 'Annuler', flat: true, noCaps: true },
|
||||
ok: { label: `Envoyer à ${n}`, color: 'negative', unelevated: true, noCaps: true },
|
||||
persistent: true,
|
||||
}).onOk(async () => {
|
||||
sendingMass.value = true
|
||||
try {
|
||||
const r = await sendInvite(eventId.value, { mass: true, channel: sendChannel.value })
|
||||
$q.notify({ type: 'positive', message: `Envoi lancé à ${r.count} destinataire(s)`, caption: 'Suivi dans Campagnes', icon: 'send', timeout: 7000, actions: [{ label: 'Ouvrir Campagnes', color: 'white', handler: goCampaigns }] })
|
||||
showSend.value = false
|
||||
load()
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Échec du lancement : ' + e.message }) } finally { sendingMass.value = false }
|
||||
})
|
||||
}
|
||||
async function sendTestNow () {
|
||||
const em = (testEmail.value || '').trim()
|
||||
if (!em) { $q.notify({ type: 'warning', message: 'Entrez un courriel de test' }); return }
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@
|
|||
|
||||
<template v-else-if="rep">
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-info">{{ cs.would_create }}</div><div class="lbl">Comptes à créer</div></q-card></div>
|
||||
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-info">{{ cs.would_create }}</div><div class="lbl">Comptes à créer <HelpHint title="Créer tout (F → OPS)">Crée dans ERPNext <b>tous</b> les comptes F qui n'ont pas encore de fiche client — pas seulement ceux facturés récemment. Politique <b>F autoritaire</b> : le nom n'est jamais écrasé, le statut est dérivé des services (jamais imposé), et l'opération est <b>idempotente</b> (relançable sans doublon).</HelpHint></div><q-btn v-if="cs.would_create" dense unelevated color="info" size="sm" no-caps icon="group_add" label="Créer tout" class="q-mt-xs" :disable="syncing" @click="confirmCreateAll"><q-tooltip>Créer dans ERPNext tous les comptes F qui n'existent pas encore (F→OPS), pas seulement ceux facturés récemment.</q-tooltip></q-btn></q-card></div>
|
||||
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-green-8">{{ cs.safe_add }}</div><div class="lbl">À enrichir (sûr)</div></q-card></div>
|
||||
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-warning">{{ cs.needs_review }}</div><div class="lbl">À réviser (actionnable)</div></q-card></div>
|
||||
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-grey-7">{{ cs.unchanged }}</div><div class="lbl">Inchangés</div></q-card></div>
|
||||
|
|
@ -230,6 +230,7 @@
|
|||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useQuasar } from 'quasar'
|
||||
import { HUB_URL } from 'src/config/hub'
|
||||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
|
||||
|
|
@ -346,6 +347,10 @@ async function showDetail (field) {
|
|||
reviewDetail.value = { field, items: d.sample || [], total: d.would_apply || 0 }
|
||||
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
||||
}
|
||||
// Créer TOUS les comptes F manquants (F→OPS complet) — passe par le run ASYNC de l'orchestrateur (scope 'customers') → progression + polling, pas de requête bloquante.
|
||||
function confirmCreateAll () {
|
||||
$q.dialog({ title: 'Créer tous les comptes manquants', message: `Créer dans ERPNext les <b>${cs.value.would_create}</b> compte(s) F sans Customer (incl. résiliés) ?<br><span class="text-caption">Politique : créé visible, statut dérivé des services. Idempotent — relançable sans doublon.</span>`, html: true, cancel: true, ok: { label: 'Créer tout', color: 'info' } }).onOk(() => startSync('customers'))
|
||||
}
|
||||
function confirmApplyAll () {
|
||||
const bf = rep.value.customers.review_by_field || {}
|
||||
const detail = Object.entries(bf).filter(([f]) => f !== 'customer_name').map(([f, n]) => `${f}: ${n}`).join(' · ')
|
||||
|
|
|
|||
|
|
@ -76,8 +76,7 @@
|
|||
<q-item-section avatar><q-icon name="warehouse" color="blue-grey-7" /></q-item-section>
|
||||
<q-item-section>Point de départ (dépôt)<q-item-label caption class="ellipsis" style="max-width:210px">{{ depot && depot.address ? depot.address : 'non défini — cliquer pour situer' }}</q-item-label></q-item-section>
|
||||
</q-item>
|
||||
<q-item clickable v-close-popup @click="showTagManager = true"><q-item-section avatar><q-icon name="sell" color="teal" /></q-item-section><q-item-section>Gérer les compétences (tags)</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="speed" /></q-item-section><q-item-section>Cadence équipe</q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openTeamEditor"><q-item-section avatar><q-icon name="groups" color="teal" /></q-item-section><q-item-section>Équipe — compétences · cadence · coût<q-item-label caption>Compétences (score + cadence/skill) · coût · catalogue</q-item-label></q-item-section></q-item>
|
||||
<q-item clickable v-close-popup @click="openJobChar"><q-item-section avatar><q-icon name="timer" color="warning" /></q-item-section><q-item-section>Durées par caractéristique</q-item-section></q-item>
|
||||
<q-item clickable>
|
||||
<q-item-section avatar><q-icon name="bookmark" color="brown" /></q-item-section>
|
||||
|
|
@ -255,38 +254,7 @@
|
|||
</div>
|
||||
<!-- Pool « À assigner » : chips de filtre (compétence) + tri (jour sélectionné d'abord) + liste BORNÉE (scroll interne, pas d'étirement). -->
|
||||
<div class="pm-pool">
|
||||
<div class="pm-pool-hd"><q-icon name="inbox" size="16px" class="q-mr-xs" />À assigner <span class="pm-count">{{ poolJobs.length }}</span><q-space /><span class="text-grey-5 q-mr-sm" style="font-size:10px;display:inline-flex;align-items:center;gap:1px">prio<i class="pm-leg" style="background:#ef4444"></i><i class="pm-leg" style="background:#f59e0b"></i><i class="pm-leg" style="background:#9e9e9e"></i></span><q-select v-model="poolSort" :options="poolSortOpts" dense options-dense borderless emit-value map-options class="pm-sort"><template #prepend><q-icon name="sort" size="15px" color="grey-6" /></template></q-select></div>
|
||||
<div v-if="poolSkills.length" class="pm-chips">
|
||||
<button v-for="s in poolSkills" :key="s.skill" type="button" class="pm-chip" :class="{ on: poolSkillSel.has(s.skill) }" :style="poolSkillSel.has(s.skill) ? { background: getTagColor(s.skill), borderColor: getTagColor(s.skill), color: '#fff' } : {}" @click="togglePoolSkill(s.skill)">{{ s.skill }} {{ s.n }}</button>
|
||||
<button v-if="poolSkillSel.size" type="button" class="pm-chip pm-chip-x" @click="poolSkillSel = new Set()">✕ tout</button>
|
||||
</div>
|
||||
<div class="pm-pool-list">
|
||||
<!-- Façon Gmail : GLISSER → action (droite = reporter +1 j · gauche = sheet Options) + icônes DIRECTES (★ urgent · 📝 note · ⋮ options). Tap = sélectionner.
|
||||
Swipe MAISON (pointer events) : le tap reste un vrai clic sur le bouton (jamais cassé), le glissement n'agit qu'au drag horizontal franc. -->
|
||||
<template v-for="(j, pi) in poolJobs" :key="j.name"><div v-if="poolSort === 'sector' && (pi === 0 || jobCity(j) !== jobCity(poolJobs[pi - 1]))" class="pm-sector-hd" style="display:flex;align-items:center;gap:4px;padding:5px 8px;margin:6px 0 2px;background:#eef2f7;border-radius:6px;font-size:11px;font-weight:600;color:#475569;position:sticky;top:0;z-index:2;cursor:pointer" @click="selectAllSector(jobCity(j) || 'Sans secteur')"><q-icon name="place" size="13px" color="green-5" />{{ jobCity(j) || 'Sans secteur' }}<q-icon name="done_all" size="14px" color="primary" class="q-ml-xs"><q-tooltip class="bg-grey-9">Sélectionner / désélectionner tout ce secteur (puis « Assigner »)</q-tooltip></q-icon><span style="margin-left:auto;font-weight:400;color:#94a3b8">{{ (poolSectorCounts[jobCity(j) || 'Sans secteur'] || {}).n }} · {{ fmtMin((poolSectorCounts[jobCity(j) || 'Sans secteur'] || {}).mins || 0) }}</span></div><div class="pm-swipe">
|
||||
<div class="pm-swipe-bg"><span class="pm-swipe-hint l"><q-icon name="check_circle" size="17px" /> Sélectionner</span><span class="pm-swipe-hint r">Sélectionner <q-icon name="check_circle" size="17px" /></span></div>
|
||||
<button type="button" class="pm-job" :class="{ sel: selectedJobs[j.name], hold: j.status === 'On Hold', swiping: swipe.active && swipe.name === j.name }" :style="{ borderLeft: '4px solid ' + prioColor(j.priority), ...swipeStyle(j) }"
|
||||
@click="onJobClick(j)" @pointerdown="swipeStart($event, j)" @pointermove="swipeMove($event)" @pointerup="swipeEnd($event, j)" @pointercancel="swipeReset">
|
||||
<q-icon :name="selectedJobs[j.name] ? 'check_circle' : (jobIsOnsite(j) ? 'home_repair_service' : 'cloud')" size="16px" :color="selectedJobs[j.name] ? 'primary' : (jobIsOnsite(j) ? 'teal' : 'grey-5')" class="q-mr-xs" />
|
||||
<span class="pm-job-main">
|
||||
<span class="pm-job-t ellipsis">{{ j.subject || j.service_type || j.name }}</span>
|
||||
<span class="pm-job-s ellipsis">{{ j.required_skill || '—' }}<template v-if="j.customer_name"> · {{ j.customer_name }}</template></span>
|
||||
<span v-if="j.scheduled_date || j.creation" class="pm-job-d">
|
||||
<span v-if="j.scheduled_date" :class="isOverdue(j.scheduled_date) ? 'text-warning text-weight-medium' : 'text-grey-6'">📅 {{ fmtDueLabel(j.scheduled_date) }}</span>
|
||||
<span v-if="j.creation" class="text-grey-5"> · créé {{ String(j.creation).slice(0, 10) }}</span>
|
||||
</span>
|
||||
<span v-if="j.notes" class="pm-job-note ellipsis"><q-icon name="sticky_note_2" size="12px" color="warning" /> {{ j.notes }}</span>
|
||||
</span>
|
||||
<span class="pm-job-icons">
|
||||
<q-icon :name="j.priority === 'high' ? 'star' : 'star_border'" :color="j.priority === 'high' ? 'amber-7' : 'grey-5'" size="21px" @click.stop="toggleUrgent(j)"><q-tooltip>{{ j.priority === 'high' ? 'Urgent — toucher pour retirer' : 'Marquer urgent (priorité, heure fixée / SLA)' }}</q-tooltip></q-icon>
|
||||
<q-icon :name="j.notes ? 'sticky_note_2' : 'note_add'" :color="j.notes ? 'deep-orange-6' : 'grey-4'" size="19px" @click.stop="openJobSheet(j, { note: true })"><q-tooltip>Note importante / détails</q-tooltip></q-icon>
|
||||
<q-icon name="more_vert" color="grey-6" size="19px" @click.stop="openJobSheet(j)"><q-tooltip>Options (priorité, statut, compétence, report)</q-tooltip></q-icon>
|
||||
</span>
|
||||
<span class="pm-job-h">{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}</span>
|
||||
</button>
|
||||
</div></template>
|
||||
<div v-if="!poolJobs.length" class="pm-empty">Rien à assigner 🎉</div>
|
||||
</div>
|
||||
<JobPool variant="mobile" :pool="mobilePool" :selected-day="mobileSelDay" />
|
||||
</div>
|
||||
<!-- Feuille d'assignation (overlay bas) : techs candidats — n'écrase PAS la liste (zéro décalage au défilement). -->
|
||||
<div v-if="selectedNames.length" class="pm-sheet">
|
||||
|
|
@ -502,6 +470,8 @@
|
|||
<q-space />
|
||||
<q-btn dense unelevated no-caps size="sm" color="deep-purple-6" text-color="white" icon="forward_to_inbox" label="Notifier" :disable="!dayRoutes.length" @click="openNotifyDlg"><q-tooltip>Envoyer à chaque tech (SMS/courriel) le lien de sa tournée + les infos clés (adresse · AM/PM · urgent). Aperçu avant envoi.</q-tooltip></q-btn>
|
||||
<q-btn dense unelevated no-caps size="sm" :color="showLivePos ? 'teal-6' : 'grey-4'" :text-color="showLivePos ? 'white' : 'grey-8'" icon="my_location" :label="'GPS live' + (dayLivePositions.length ? ' (' + dayLivePositions.length + ')' : '')" @click="showLivePos = !showLivePos"><q-tooltip>Positions GPS des techs (Traccar), rafraîchies ~25 s. Cliquer pour {{ showLivePos ? 'masquer' : 'afficher' }}.</q-tooltip></q-btn>
|
||||
<!-- Tracé GPS RÉEL du jour — visible seulement quand UNE tournée est affichée (naturellement seule, ou isolée via les chips techs) -->
|
||||
<q-btn v-if="soloRouteTech" dense unelevated no-caps size="sm" :color="showRouteTrack ? 'deep-orange-6' : 'grey-4'" :text-color="showRouteTrack ? 'white' : 'grey-8'" icon="timeline" :label="'Tracé réel' + (showRouteTrack && routeTrackMsg ? ' · ' + routeTrackMsg : '')" @click="showRouteTrack = !showRouteTrack"><q-tooltip>Parcours GPS réel (Traccar) de {{ soloRouteTech.name }} ce jour — trait orange superposé à la tournée prévue. S'affiche quand une seule tournée est isolée.</q-tooltip></q-btn>
|
||||
<span class="text-caption text-grey-6">{{ dayRoutes.length }} tournée(s) · clic tech = zoom · clic arrêt = détail</span>
|
||||
</div>
|
||||
<!-- Chips techs : clic = ISOLER la tournée de ce tech (masquer les autres) ; re-clic = tout réafficher. Pastille = nb de jobs, couleur du tech. -->
|
||||
|
|
@ -538,7 +508,7 @@
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
|
||||
<RouteMap ref="routesMapRef" :routes="dayRoutes" :live="showLivePos ? dayLivePositions : []" :pins="routeUnassignedPins" :track="showRouteTrack ? routeTrack : null" height="62vh" @metrics="m => Object.assign(dayRouteMetrics, m)" @stop-click="onRoutesStopClick" @pin-click="p => openJobDetail(p.job)" />
|
||||
<div class="suggest-legend q-mt-xs">
|
||||
<span v-for="r in dayRoutes" :key="r.id" class="suggest-leg" style="cursor:pointer" @click="routesMapRef && routesMapRef.fitTo(r.id)" @mouseenter="routesMapRef && routesMapRef.setActive(r.id)" @mouseleave="routesMapRef && routesMapRef.setActive(null)">
|
||||
<span class="suggest-leg-dot" :style="{ background: r.color }"></span>{{ r.name }}
|
||||
|
|
@ -551,19 +521,7 @@
|
|||
<div v-if="boardView === 'kanban'" class="kbb-wrap">
|
||||
<!-- Pool « À assigner » : liste verticale + recherche + tri (priorité/ville/distance/compétence/durée) -->
|
||||
<div class="kbb-pool" :class="{ 'drop-hover': dropCell === '__pool__' }" @dragover.prevent="dropCell = '__pool__'" @dragleave="dropCell = null" @drop="onKanbanUnassign">
|
||||
<div class="kbb-pool-hd"><q-icon name="inbox" size="15px" /> À assigner <span class="kb-count">{{ kanbanPoolView.length }}</span></div>
|
||||
<div class="kbb-pool-tools">
|
||||
<q-input dense outlined v-model="kbSearch" placeholder="Rechercher…" clearable><template #prepend><q-icon name="search" size="16px" /></template></q-input>
|
||||
<q-select dense outlined v-model="kbSort" emit-value map-options :options="[{ label: 'Priorité', value: 'prio' }, { label: 'Ville', value: 'city' }, { label: 'Distance (dépôt)', value: 'dist' }, { label: 'Compétence', value: 'skill' }, { label: 'Durée', value: 'dur' }]"><template #prepend><q-icon name="sort" size="16px" /></template></q-select>
|
||||
</div>
|
||||
<div class="kbb-pool-body">
|
||||
<div v-for="j in kanbanPoolView" :key="j.name" class="kb-card" :class="{ hold: j.status === 'On Hold', dragging: draggingSet.has(j.name) }" :style="{ borderLeftColor: kbColor(j) }" :draggable="j.status !== 'On Hold'" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||||
<div class="kb-card-t"><q-icon :name="skillSym(j.required_skill)" size="13px" :style="{ color: kbColor(j) }" /> {{ j.subject || j.service_type || j.name }}</div>
|
||||
<div class="kb-card-m"><span class="kb-dot" :style="{ background: prioColor(j.priority) }"></span>{{ j.est_min ? fmtMin(j.est_min) : ((j.duration_h || 1) + 'h') }}<template v-if="kbCity(j)"> · {{ kbCity(j) }}</template></div>
|
||||
<q-tooltip class="bg-grey-9" :delay="350" style="font-size:11px">{{ j.subject || j.service_type }}<template v-if="j.customer_name"><br>{{ j.customer_name }}</template><template v-if="j.address"><br>📍 {{ j.address }}</template><template v-if="j.required_skill"><br>🧩 {{ j.required_skill }}</template><template v-if="j.status === 'On Hold'"><br>🔒 en attente — non assignable</template></q-tooltip>
|
||||
</div>
|
||||
<div v-if="!kanbanPoolView.length" class="kb-empty">— rien à assigner —</div>
|
||||
</div>
|
||||
<JobPool variant="docked" :pool="kanbanPool" />
|
||||
</div>
|
||||
|
||||
<!-- Board : techs en lanes horizontales + échelle d'heures (la liste de techs suit le filtre skill/sous-groupe) -->
|
||||
|
|
@ -614,25 +572,73 @@
|
|||
<LeaveDialog v-model="showLeave" :techs="techs" :tech-options="techOptions" />
|
||||
|
||||
<q-dialog v-model="showTeamEditor">
|
||||
<q-card style="min-width:900px">
|
||||
<q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — cadence & coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||||
<q-card style="min-width:640px;max-width:96vw">
|
||||
<q-card-section class="row items-center q-pb-none"><div class="text-subtitle1 text-weight-bold">Équipe — compétences, cadence & coût</div><q-space /><q-btn flat round dense icon="close" v-close-popup /></q-card-section>
|
||||
<q-card-section>
|
||||
<div class="text-caption text-grey-7 q-mb-sm">Cadence : 1.00 normal · 1.10 = +10 % (plus lent) · 0.90 = −10 % (plus rapide). Coût chargé/h = salaire × (1 + charges %) + autres (véhicule, outils, frais). Le solveur préfère les techs rapides et moins coûteux.</div>
|
||||
<div style="max-height:55vh;overflow:auto">
|
||||
<table class="demand-tbl" style="width:100%">
|
||||
<thead><tr><th>Technicien</th><th>Compétences</th><th>Efficacité %</th><th>Salaire/h</th><th>Charges %</th><th>Autres/h</th><th>Coût chargé/h</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="t in editTechs" :key="t.id">
|
||||
<td>{{ t.name }}<span v-if="t.group" class="grp">{{ t.group }}</span></td>
|
||||
<td><TagEditor :model-value="Array.isArray(t.skills) ? t.skills : []" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" compact placeholder="+ compétence" style="min-width:160px" @update:model-value="items => onTeamSkills(t, items)" @create="onCreateRosterTag" /></td>
|
||||
<td><q-input dense outlined type="number" step="10" min="20" max="1000" :model-value="(t.id in gEffBuf) ? gEffBuf[t.id] : effPctOf(t.efficiency)" @update:model-value="v => gEffBuf[t.id] = v" @blur="commitGEff(t)" @keyup.enter="commitGEff(t)" style="width:92px" suffix="%"><q-tooltip>100 % = cadence normale · PLUS HAUT = plus rapide (fait plus de jobs). Ex. 300 % = 3× plus rapide · 60 % = plus lent.</q-tooltip></q-input></td>
|
||||
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.salary" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td><q-input dense outlined type="number" step="1" v-model.number="t.charges" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td><q-input dense outlined type="number" step="0.5" v-model.number="t.other" style="width:80px" @blur="saveCost(t)" /></td>
|
||||
<td class="text-weight-bold text-center">{{ loadedCost(t) }} $</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Gérer les compétences (catalogue global) — fusionné ici : renommer / recolorer / supprimer partout -->
|
||||
<q-expansion-item dense icon="sell" label="Gérer les compétences (catalogue)" caption="Renommer · recolorer · supprimer — partout" header-class="text-teal-8" class="q-mb-xs">
|
||||
<div class="q-pa-sm">
|
||||
<div v-if="!managedTags.length" class="text-grey-6 text-caption">Aucune compétence — ajoute-en via un technicien ci-dessous.</div>
|
||||
<q-list v-else dense separator>
|
||||
<q-item v-for="tg in managedTags" :key="tg.label">
|
||||
<q-item-section avatar>
|
||||
<q-btn flat dense round size="sm" icon="circle" :style="{ color: tg.color }"><q-tooltip>Couleur</q-tooltip>
|
||||
<q-menu><div class="q-pa-xs" style="width:208px">
|
||||
<div class="row"><q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: tg.label, color: c })" /></div>
|
||||
<div class="row items-center no-wrap q-mt-xs q-px-xs"><span class="text-caption text-grey-7 q-mr-sm">Perso</span><input type="color" :value="tg.color" @change="e => onUpdateRosterTag({ name: tg.label, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" /></div>
|
||||
</div></q-menu>
|
||||
</q-btn>
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="cursor-pointer">{{ tg.label }}
|
||||
<q-popup-edit :model-value="tg.label" auto-save v-slot="scope" @save="v => renameTagGlobal(tg.label, v)">
|
||||
<q-input dense autofocus :model-value="scope.value" @update:model-value="scope.value = $event" label="Renommer (partout)" @keyup.enter="scope.set" />
|
||||
</q-popup-edit>
|
||||
</q-item-label>
|
||||
<q-item-label caption>{{ tg.count }} technicien(s) · clic = renommer</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side><q-btn flat dense round size="sm" icon="delete" color="grey-6" @click="deleteTagGlobal(tg)"><q-tooltip>Supprimer partout</q-tooltip></q-btn></q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
<q-separator class="q-my-sm" />
|
||||
|
||||
<div class="text-caption text-grey-7 q-mb-xs">Clique un technicien pour régler ses <b>compétences</b> (ordre = priorité), son <b>score</b> ★ et sa <b>cadence par compétence</b> (défaut global 100 %), plus son <b>coût</b>. Enregistrement automatique.</div>
|
||||
<div style="max-height:58vh;overflow:auto">
|
||||
<q-list separator>
|
||||
<q-expansion-item v-for="t in editTechs" :key="t.id" dense group="teamTech">
|
||||
<template #header>
|
||||
<q-item-section>
|
||||
<div class="row items-center no-wrap">
|
||||
<span class="text-weight-medium">{{ t.name }}</span><span v-if="t.group" class="grp q-ml-xs">{{ t.group }}</span>
|
||||
<q-space />
|
||||
<span class="text-caption text-grey-6">{{ (t.skills || []).length }} comp. · cad. {{ Math.round(100 / (Number(t.efficiency) || 1)) }} % · {{ loadedCostRow(t.id) }} $/h</span>
|
||||
</div>
|
||||
<div class="row items-center q-gutter-xs q-mt-xs">
|
||||
<span v-for="sk in (t.skills || []).slice(0, 7)" :key="sk" class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}</span>
|
||||
<span v-if="!(t.skills || []).length" class="text-caption text-grey-5">aucune compétence</span>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</template>
|
||||
<div class="q-pa-sm">
|
||||
<SkillCadenceTable :tech="t" :catalog="tagCatalog" :get-color="getTagColor" :palette="TAG_PALETTE" show-global
|
||||
@tags-change="items => onTagsChange(t, items)"
|
||||
@set-level="e => setSkillLevel(t, e.sk, e.level)"
|
||||
@set-cadence="e => setSkillEffPct(t, e.sk, e.pct)"
|
||||
@set-global="pct => setGlobalCadence(t, pct)"
|
||||
@create-tag="onCreateRosterTag" @update-tag="onUpdateRosterTag" />
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">💵 Coût <span class="text-grey-5 text-weight-regular">chargé/h = salaire × (1 + charges %) + autres</span></div>
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-input dense outlined type="number" step="0.5" v-model.number="teamCost[t.id].salary" style="width:118px" label="Salaire/h" @blur="saveCostRow(t)" />
|
||||
<q-input dense outlined type="number" step="1" v-model.number="teamCost[t.id].charges" style="width:118px" label="Charges %" @blur="saveCostRow(t)" />
|
||||
<q-input dense outlined type="number" step="0.5" v-model.number="teamCost[t.id].other" style="width:118px" label="Autres/h" @blur="saveCostRow(t)" />
|
||||
<div class="text-weight-bold text-center">= {{ loadedCostRow(t.id) }} $/h</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
</q-list>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
|
@ -733,43 +739,13 @@
|
|||
<q-menu v-model="skillMenuShown" :target="skillMenuTarget" anchor="bottom left" self="top left" no-focus max-height="80vh">
|
||||
<div v-if="skillDialog" class="q-pa-sm" style="width:368px;min-height:300px" @click.stop @mousedown.stop>
|
||||
<div class="row items-center q-mb-xs"><div class="text-subtitle2 text-weight-bold col ellipsis">🏷 {{ skillDialog.name }}</div><q-btn flat dense round size="sm" icon="close" v-close-popup /></div>
|
||||
<TagEditor :model-value="skillDialog.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" sortable placeholder="Cliquez pour ajouter une compétence…"
|
||||
@update:model-value="items => onTagsChange(skillDialog, items)"
|
||||
@create="onCreateRosterTag" />
|
||||
<div v-if="(skillDialog.skills || []).length > 1" class="text-caption text-grey-6 q-mt-xs" style="line-height:1.3">↔ <b>Glisse les chips</b> pour l'ordre de priorité — la <b>1<sup>re</sup> (★)</b> = fonction principale : le dispatch auto envoie ces jobs à ce tech <b>en premier</b> (épargne les moins spécialisés).</div>
|
||||
<div v-if="(skillDialog.skills || []).length" class="q-mt-md">
|
||||
<div class="row items-center text-caption text-grey-6 q-pb-xs">
|
||||
<div class="col">Compétence</div>
|
||||
<div style="width:90px" class="text-center">Score</div>
|
||||
<div style="width:88px" class="text-center">Cadence <HelpHint title="Cadence (vitesse)" text="Vitesse du technicien pour cette compétence. 100 % = cadence normale ; plus haut = plus rapide (fait plus de jobs, ex. 200 % = deux fois plus) ; sous 100 % = plus lent. Vide = hérite de la cadence globale du tech. Le dispatch auto en tient compte pour estimer les durées." /></div>
|
||||
</div>
|
||||
<div v-for="(sk, si) in skillDialog.skills" :key="sk" class="row items-center no-wrap q-py-xs" style="border-top:1px solid #eee">
|
||||
<div class="col row items-center no-wrap">
|
||||
<span class="skill-rank" :class="{ 'skill-rank-1': si === 0 }" :title="si === 0 ? 'Priorité 1 — compétence principale' : 'Priorité ' + (si + 1)">{{ si + 1 }}</span>
|
||||
<q-btn flat dense round size="xs" icon="circle" :style="{ color: getTagColor(sk) }"><q-tooltip>Couleur</q-tooltip>
|
||||
<q-menu><div class="q-pa-xs" style="width:208px">
|
||||
<div class="row">
|
||||
<q-btn v-for="c in TAG_PALETTE" :key="c" v-close-popup flat dense round size="xs" icon="circle" :style="{ color: c }" @click="onUpdateRosterTag({ name: sk, color: c })" />
|
||||
</div>
|
||||
<div class="row items-center no-wrap q-mt-xs q-px-xs">
|
||||
<span class="text-caption text-grey-7 q-mr-sm">Perso</span>
|
||||
<input type="color" :value="getTagColor(sk)" @change="e => onUpdateRosterTag({ name: sk, color: e.target.value })" style="width:42px;height:26px;border:1px solid #ddd;border-radius:4px;background:none;cursor:pointer;padding:0" />
|
||||
<span class="text-caption text-grey-5 q-ml-sm">toute couleur</span>
|
||||
</div>
|
||||
</div></q-menu>
|
||||
</q-btn>
|
||||
<span class="skill-chip" :style="{ background: getTagColor(sk) }">{{ sk }}</span>
|
||||
</div>
|
||||
<div style="width:90px" class="text-center no-wrap">
|
||||
<q-icon v-for="n in 5" :key="n" :name="skillLevelOf(skillDialog, sk) >= n ? 'star' : 'star_outline'" :color="skillLevelOf(skillDialog, sk) >= n ? 'indigo' : 'grey-4'" size="16px" class="cursor-pointer" @click="setSkillLevel(skillDialog, sk, skillLevelOf(skillDialog, sk) === n ? 0 : n)" />
|
||||
</div>
|
||||
<div style="width:88px">
|
||||
<q-input dense outlined type="number" step="25" min="20" max="1000" :model-value="(effBufKey(skillDialog, sk) in effBuf) ? effBuf[effBufKey(skillDialog, sk)] : skillEffPct(skillDialog, sk)" @update:model-value="v => { effBuf[effBufKey(skillDialog, sk)] = v }" @blur="commitSkillEff(skillDialog, sk)" @keyup.enter="commitSkillEff(skillDialog, sk)" suffix="%" placeholder="glob." input-class="text-right" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-caption text-grey-6 q-mt-xs"><b>Score</b> ★ = maîtrise · <b>Cadence</b> : <b>100 %</b> = normale · <b>200 %</b> = deux fois plus de jobs · < 100 % = plus lent · vide = hérite du global · × sur le chip = retirer.</div>
|
||||
<div class="text-caption text-green-7 q-mt-xs row items-center no-wrap" style="gap:3px"><q-icon name="cloud_done" size="13px" />Enregistré automatiquement — pas de bouton à cliquer.</div>
|
||||
</div>
|
||||
<!-- Éditeur compétences/cadence PARTAGÉ (même gabarit que l'outil « Équipe ») -->
|
||||
<SkillCadenceTable :tech="skillDialog" :catalog="tagCatalog" :get-color="getTagColor" :palette="TAG_PALETTE"
|
||||
@tags-change="items => onTagsChange(skillDialog, items)"
|
||||
@set-level="e => setSkillLevel(skillDialog, e.sk, e.level)"
|
||||
@set-cadence="e => setSkillEffPct(skillDialog, e.sk, e.pct)"
|
||||
@create-tag="onCreateRosterTag" @update-tag="onUpdateRosterTag" />
|
||||
<div v-if="(skillDialog.skills || []).length" class="text-caption text-green-7 q-mt-xs row items-center no-wrap" style="gap:3px"><q-icon name="cloud_done" size="13px" />Enregistré automatiquement — pas de bouton à cliquer.</div>
|
||||
<q-separator class="q-my-sm" />
|
||||
<div class="text-caption text-weight-medium text-grey-7 q-mb-xs">🗓 Horaire</div>
|
||||
<div class="row items-center q-gutter-xs">
|
||||
|
|
@ -882,36 +858,13 @@
|
|||
<!-- Panneau FLOTTANT déplaçable : jobs à assigner (groupes parent-enfant) → glisser sur une case (tech × jour) -->
|
||||
<div v-if="assignPanel.open" class="assign-panel" :style="{ left: assignPanel.x + 'px', top: assignPanel.y + 'px', width: assignPanel.w + 'px', height: assignPanel.h + 'px', maxHeight: '92vh' }">
|
||||
<div class="assign-hdr" @mousedown="panelHeaderDown">
|
||||
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ assignTypeFilter.length ? assignJobsFiltered.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
|
||||
<q-icon name="drag_indicator" size="18px" /><span>Jobs à assigner ({{ floatingPool.skillFilter.length ? floatingPool.filteredJobs.length + '/' + assignPanel.jobs.length : assignPanel.jobs.length }})</span><q-space />
|
||||
<q-btn v-if="assignNoCoord" flat dense no-caps size="sm" color="warning" icon="wrong_location" :label="String(assignNoCoord)" :loading="assignPanel.locating" class="q-mr-xs" @click="batchLocatePool"><q-tooltip>Localiser les {{ assignNoCoord }} job(s) sans coordonnées : adresse → base RQA + GPS (ils apparaissent ensuite sur la carte)</q-tooltip></q-btn>
|
||||
<q-btn flat dense no-caps size="sm" color="amber-4" icon="auto_awesome" :label="suggestFiltered ? 'Suggérer (' + suggestJobCount + ')' : 'Suggérer'" :loading="suggestDlg.building" class="q-mr-xs" @click="openSuggest"><q-tooltip>Proposer une répartition optimisée (proximité · charge · compétence){{ suggestFiltered ? ' — ' + suggestJobCount + ' job(s) ' + suggestScope : '' }} — tu revois et ajustes avant d'appliquer</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="map" :color="assignPanel.showMap ? 'amber-4' : 'white'" @click="toggleAssignMap"><q-tooltip>Carte des jobs (regrouper par région)</q-tooltip></q-btn>
|
||||
<q-btn flat dense round size="sm" icon="refresh" color="white" :loading="assignPanel.loading" @click="openAssignPanel" />
|
||||
<q-btn flat dense round size="sm" icon="close" color="white" @click="assignPanel.open = false" />
|
||||
</div>
|
||||
<div class="assign-sortbar" @mousedown.stop>
|
||||
<span>Trier :</span>
|
||||
<select v-model="assignSort" @mousedown.stop>
|
||||
<option value="group">Groupe (parent-enfant)</option>
|
||||
<option value="skill">Compétence</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="city">Ville</option>
|
||||
<option value="priority">Priorité</option>
|
||||
</select>
|
||||
<q-btn dense flat size="sm" :icon="assignSortDir === 'asc' ? 'arrow_upward' : 'arrow_downward'" @click="assignSortDir = assignSortDir === 'asc' ? 'desc' : 'asc'" @mousedown.stop><q-tooltip>{{ assignSortDir === 'asc' ? 'Croissant (ancien → récent)' : 'Décroissant (récent → ancien)' }}</q-tooltip></q-btn>
|
||||
<q-btn dense flat size="sm" :icon="allCollapsed ? 'unfold_more' : 'unfold_less'" no-caps @click="toggleCollapseAll" @mousedown.stop><q-tooltip>{{ allCollapsed ? 'Tout déplier' : 'Tout replier (voir un seul groupe)' }}</q-tooltip></q-btn>
|
||||
<q-space />
|
||||
<q-btn v-if="selectedNames.length" dense flat size="sm" icon="deselect" :label="'✕ ' + selectedNames.length" no-caps color="grey-7" @click="clearSel" @mousedown.stop><q-tooltip>Tout désélectionner</q-tooltip></q-btn>
|
||||
</div>
|
||||
<div v-if="assignTypes.length > 1" class="assign-chips" @mousedown.stop>
|
||||
<span v-for="t in assignTypes" :key="t.k" class="assign-chip-f" :style="assignTypeFilter.includes(t.k) ? { background: getTagColor(t.k), color: '#fff', borderColor: getTagColor(t.k) } : { borderColor: getTagColor(t.k) }" @click="toggleAssignType(t.k)">{{ t.k }} <b>{{ t.n }}</b></span>
|
||||
<span v-if="assignTypeFilter.length" class="assign-chip-f clear" @click="assignTypeFilter = []"><q-tooltip>Tout afficher</q-tooltip>✕</span>
|
||||
</div>
|
||||
<div v-if="assignDates.length > 1" class="assign-chips" @mousedown.stop>
|
||||
<q-icon name="event" size="14px" color="grey-6" class="q-mr-xs" />
|
||||
<span v-for="d in assignDates" :key="d.iso" class="assign-chip-f" :style="assignDateFilter.includes(d.iso) ? { background: d.overdue ? '#f59e0b' : '#6366f1', color: '#fff', borderColor: d.overdue ? '#f59e0b' : '#6366f1' } : (d.overdue ? { borderColor: '#f59e0b', color: '#b45309' } : {})" @click="toggleAssignDate(d.iso)">{{ d.label }} <b>{{ d.n }}</b></span>
|
||||
<span v-if="assignDateFilter.length" class="assign-chip-f clear" @click="assignDateFilter = []"><q-tooltip>Toutes les dates</q-tooltip>✕</span>
|
||||
</div>
|
||||
<!-- Carte des jobs à assigner : 1 pin par adresse (lettre = groupe de ≥2 jobs à la même adresse) → voir les régions, clic = sélectionner le groupe local -->
|
||||
<div v-show="assignPanel.showMap" class="assign-map-wrap" @mousedown.stop>
|
||||
<div ref="assignMapEl" class="assign-map"></div>
|
||||
|
|
@ -919,60 +872,8 @@
|
|||
<q-btn dense round size="sm" class="map-lasso-btn" :color="assignLasso ? 'primary' : 'grey-8'" icon="highlight_alt" @click="toggleLasso"><q-tooltip>{{ assignLasso ? 'Lasso ACTIF — trace une zone à main levée pour sélectionner les jobs dedans (reclique pour quitter)' : 'Lasso : encercler un groupe de jobs (tracé libre)' }}</q-tooltip></q-btn>
|
||||
<div class="assign-map-cap">📍 Pins par <b>compétence</b> · amas = <b>total de jobs</b> (clic = zoom) · <b><q-icon name="highlight_alt" size="12px" /> lasso</b> = sélectionner une zone<span v-if="assignNoCoord" class="text-warning"> · ⚠ {{ assignNoCoord }} sans coords</span></div>
|
||||
</div>
|
||||
<div class="assign-body">
|
||||
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement…</div>
|
||||
<div v-else-if="!assignPanel.jobs.length" class="text-grey-6 q-pa-md text-center">Aucun job à assigner 🎉</div>
|
||||
<div v-for="grp in assignGroups" :key="grp.key" class="assign-grp" :class="{ 'grp-hl': groupSelected(grp) }">
|
||||
<div v-if="grp.label" class="assign-grp-lbl" style="cursor:pointer;display:flex;align-items:center;gap:3px" @click="toggleCollapse(grp.key)"><q-icon :name="assignCollapsed.has(grp.key) ? 'chevron_right' : 'expand_more'" size="15px" /><span>{{ grp.label }} <span style="opacity:.6">({{ grp.jobs.length }})</span></span><q-space /><q-icon name="done_all" size="16px" :color="groupSelected(grp) ? 'primary' : 'grey-5'" @click.stop="toggleGroupSelAll(grp)"><q-tooltip>Sélectionner / désélectionner tout ce groupe</q-tooltip></q-icon></div>
|
||||
<template v-if="!(grp.label && assignCollapsed.has(grp.key))">
|
||||
<div v-if="assignSort === 'group' && grp.jobs.length > 1" class="assign-grp-hdr" @click="toggleGroupSel(grp)"><q-icon name="account_tree" size="12px" /> Groupe ({{ grp.jobs.length }}) — tout sélectionner (terrain)</div>
|
||||
<div v-for="(j, idx) in grp.jobs" :key="j.name" :data-jobname="j.name" class="assign-job" :class="{ blocked: j.status === 'On Hold', child: assignSort === 'group' && grp.jobs.length > 1 && idx > 0, sel: !!selectedJobs[j.name], dragging: draggingSet.has(j.name), 'job-flash': flashJob === j.name }" :style="{ borderLeft: '5px solid ' + panelJobColor(j) }" draggable="true" @dragstart="onJobDragStart($event, j)" @dragend="onJobDragEnd">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-checkbox dense size="xs" :model-value="!!selectedJobs[j.name]" @update:model-value="selectedJobs[j.name] = $event" @click.stop @mousedown.stop class="q-mr-xs" />
|
||||
<q-icon :name="jobIsOnsite(j) ? 'home_repair_service' : 'cloud'" size="13px" :color="jobIsOnsite(j) ? 'teal' : 'grey-5'" class="q-mr-xs"><q-tooltip>{{ jobIsOnsite(j) ? 'Sur site (terrain)' : 'À distance / netadmin — pas pour un tech terrain' }}</q-tooltip></q-icon>
|
||||
<span v-if="groupLabel(j)" class="assign-grp-badge q-mr-xs"><q-tooltip class="bg-grey-9">Même adresse de service (groupe {{ groupLabel(j)[0] }}) — simple repère de proximité</q-tooltip>{{ groupLabel(j) }}</span>
|
||||
<q-badge v-if="j.step_order" color="indigo" class="q-mr-xs">{{ j.step_order }}</q-badge>
|
||||
<span class="ellipsis text-weight-medium" :style="assignPanel.showMap ? 'cursor:pointer' : ''" @click="assignPanel.showMap && focusAssignJob(j)">{{ j.subject || j.service_type || j.name }}<q-tooltip v-if="j.legacy_detail" max-width="380px" class="bg-grey-9" style="white-space:pre-wrap;font-size:11px">{{ j.legacy_detail }}</q-tooltip></span>
|
||||
<!-- SLA : pastille rouge (dépassé) / orange (proche) — réutilise les politiques SLA des tickets -->
|
||||
<span v-if="jobSlaBadge(j)" class="assign-sla" :class="'sla-' + jobSlaBadge(j).state"><q-icon :name="jobSlaBadge(j).icon" size="11px" />{{ jobSlaBadge(j).short }}<q-tooltip class="bg-grey-9">{{ jobSlaBadge(j).label }}</q-tooltip></span>
|
||||
<q-space />
|
||||
<q-btn v-if="j.status !== 'On Hold'" flat dense round size="sm" icon="person_add" color="primary" class="q-ml-xs" @click.stop @mousedown.stop>
|
||||
<q-tooltip>Assigner à un technicien (classés par pertinence : compétence · dispo · distance · charge)</q-tooltip>
|
||||
<q-menu anchor="bottom right" self="top right" @click.stop>
|
||||
<div class="assign-pick-hd">Assigner « {{ (j.subject || j.name).slice(0, 30) }} » · 📅 {{ fmtDueLabel(jobTargetDay(j)) }}</div>
|
||||
<q-list dense style="min-width:270px;max-height:44vh;overflow:auto">
|
||||
<q-item v-for="tk in techsForJob(j)" :key="tk.id" clickable v-close-popup @click="quickAssign(j, tk)" :class="{ 'assign-pick-incap': !tk.capable }">
|
||||
<q-item-section avatar style="min-width:32px"><q-avatar size="26px" :color="tk.capable ? 'blue-grey-1' : 'grey-3'" text-color="blue-grey-8" style="font-size:10px;font-weight:700">{{ initials(tk.name) }}</q-avatar></q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>{{ tk.name }}<q-icon v-if="!tk.capable && j.required_skill" name="warning" size="12px" color="orange" class="q-ml-xs"><q-tooltip>Compétence « {{ j.required_skill }} » absente</q-tooltip></q-icon></q-item-label>
|
||||
<q-item-label caption><span v-if="tk.distKm != null">📍 {{ Math.round(tk.distKm) }} km</span><span v-else class="text-grey-5">dist. ?</span> · {{ tk.h }}/{{ tk.cap }}h<span v-if="tk.noShift" class="text-warning"> · ▲ sans quart</span><span v-else-if="tk.over" class="text-negative"> · surchargé</span></q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side><q-icon name="chevron_right" size="16px" color="grey-5" /></q-item-section>
|
||||
</q-item>
|
||||
<q-item v-if="!techsForJob(j).length"><q-item-section class="text-grey-6 text-caption">Aucun technicien visible.</q-item-section></q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
<q-icon v-if="j.legacy_ticket_id" name="forum" size="14px" :color="j._showThread ? 'indigo' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop="toggleAssignThread(j)" @mousedown.stop><q-tooltip>Voir le fil du ticket #{{ j.legacy_ticket_id }}</q-tooltip></q-icon>
|
||||
<q-icon v-if="j.status === 'On Hold'" name="lock" size="13px" color="orange"><q-tooltip>En attente de {{ j.depends_on || 'la tâche précédente' }}</q-tooltip></q-icon>
|
||||
</div>
|
||||
<div class="assign-sub">
|
||||
<span v-if="j.required_skill" class="assign-skill" :style="{ background: getTagColor(j.required_skill) }">{{ j.required_skill }}</span>
|
||||
<span v-if="j.required_skill" class="assign-lvl" :class="{ set: j.required_level > 1 }" @click.stop @mousedown.stop><q-icon name="military_tech" size="11px" />niv {{ j.required_level || 1 }}<q-tooltip class="bg-grey-9">Niveau min requis pour ce job (persistant · mode « juste ce qu'il faut » → réserve les experts)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:150px"><q-item v-for="n in [1,2,3,4,5]" :key="n" clickable v-close-popup @click="setJobReqLevel(j, n)" :active="(j.required_level||1)===n"><q-item-section>Niveau {{ n }}{{ n===1 ? ' — de base' : (n===5 ? ' — expert' : '') }}</q-item-section></q-item></q-list></q-menu></span>
|
||||
{{ j.customer_name || j.location_label || j.service_location || '' }}<span v-if="j.depends_on"> · après {{ j.depends_on }}</span> · <span class="text-positive text-weight-medium">≈ {{ j.est_min ? fmtMin(j.est_min) : (j.duration_h || 1) + 'h' }}<q-tooltip v-if="j.est_labels && j.est_labels.length" class="bg-grey-9" style="font-size:11px">Estimé (auto) : {{ j.est_labels.join(' + ') }}</q-tooltip></span><span v-if="j.scheduled_date"> · 📅 <span :class="isOverdue(j.scheduled_date) ? 'text-warning text-weight-bold' : 'text-grey-7'">{{ fmtDueLabel(j.scheduled_date) }}</span><q-icon name="edit_calendar" size="14px" :color="isOverdue(j.scheduled_date) ? 'warning' : 'grey-5'" class="q-ml-xs" style="cursor:pointer" @click.stop @mousedown.stop><q-tooltip>Replanifier (aujourd'hui / demain / date)</q-tooltip><q-menu anchor="bottom left" self="top left" @click.stop><q-list dense style="min-width:160px"><q-item clickable v-close-popup @click="setJobDate(j, todayISO())"><q-item-section avatar style="min-width:28px"><q-icon name="today" size="18px" color="primary" /></q-item-section><q-item-section>Aujourd'hui</q-item-section></q-item><q-item clickable v-close-popup @click="setJobDate(j, tomorrowISO())"><q-item-section avatar style="min-width:28px"><q-icon name="event" size="18px" color="primary" /></q-item-section><q-item-section>Demain</q-item-section></q-item><q-separator /><q-date :model-value="j.scheduled_date" @update:model-value="v => setJobDate(j, v)" mask="YYYY-MM-DD" minimal today-btn /></q-list></q-menu></q-icon></span>
|
||||
</div>
|
||||
<div v-if="j._showThread" class="assign-thread" @mousedown.stop @click.stop>
|
||||
<div v-if="j._thread && j._thread.loading" class="text-grey-6" style="font-size:11px"><q-spinner size="12px" class="q-mr-xs" />Chargement du fil…</div>
|
||||
<template v-else-if="j._thread && j._thread.messages && j._thread.messages.length">
|
||||
<div v-for="(m, mi) in j._thread.messages" :key="mi" class="assign-msg"><b>{{ m.author }}</b><span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><div class="assign-msg-txt">{{ m.text }}</div></div>
|
||||
</template>
|
||||
<div v-else class="text-grey-6" style="font-size:11px;white-space:pre-wrap">{{ j.legacy_detail || 'Aucun fil pour ce ticket.' }}</div>
|
||||
<div class="text-right q-mt-xs"><q-btn dense flat size="sm" color="negative" icon="block" label="Fermer ce ticket (legacy)" @click="closeLegacyTicket(j)"><q-tooltip>Ticket inutile → status closed dans osTicket + sort du dispatch</q-tooltip></q-btn></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="assignPanel.loading" class="text-grey-6 q-pa-md text-center">Chargement…</div>
|
||||
<JobPool v-else variant="floating" :pool="floatingPool" />
|
||||
<div v-if="assignPanel.jobs.length" class="assign-foot">
|
||||
<template v-if="selectedNames.length"><b>{{ selectedNames.length }}</b> sélectionné(s) · <b>{{ selectedHours }}h</b>
|
||||
<q-btn dense unelevated size="sm" color="primary" icon="person_add" :label="'Assigner (' + selectedNames.length + ')'" class="q-ml-sm">
|
||||
|
|
@ -1378,97 +1279,61 @@
|
|||
|
||||
<q-dialog v-model="jobDetail.open" position="right" full-height>
|
||||
<q-card class="jd-card">
|
||||
<q-card-section class="row items-center q-pb-sm">
|
||||
<!-- Barre d'en-tête = titre + FERMER uniquement. Le X ne partage plus la ligne du champ d'assignation (ambiguïté « supprime-t-il le champ ? ») ; il est étiqueté « Fermer ». -->
|
||||
<q-card-section class="row items-center no-wrap q-pb-sm">
|
||||
<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"><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"
|
||||
doctype="Dispatch Job" :doc-name="jobDetail.name"
|
||||
:assignee="jdAssignee" :assistants="jdAssistants"
|
||||
:tech-options="jdTeamOptions" :can-onsite="jobDetail.canTeam" :assist-loading="jobDetail.teamLoading"
|
||||
@assign="jdAssignTech" @set-assistant="jdSetAssistant" @unassign="jdUnassign" @remove-assistant="jdRemoveAssistant">
|
||||
<template #right>
|
||||
<TicketStatusControl v-if="jobDetail.canTeam" doctype="Dispatch Job" :docname="jobDetail.name" :status="jobDetail.status" :scheduled-date="jobDetail.iso"
|
||||
@changed="p => { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
|
||||
</template>
|
||||
</AssignmentField>
|
||||
</div>
|
||||
<q-space />
|
||||
<q-btn flat round dense icon="close" v-close-popup />
|
||||
<q-btn flat round dense icon="close" v-close-popup><q-tooltip>Fermer</q-tooltip></q-btn>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<!-- Assignation MULTIFONCTION — module partagé JobTeamField (À = assigné · CC = assistant · # = follower).
|
||||
Les assistants sont gérés par le module (roster) ; le LEAD reste sur la pipeline board (assignNames via jdAssignTech / jdUnassign).
|
||||
Le MÊME module est monté dans le détail ticket → une seule source. -->
|
||||
<q-card-section class="q-py-sm">
|
||||
<JobTeamField class="jd-assign"
|
||||
:name="jobDetail.name" :tech-id="jobDetail.techId" :tech-name="jobDetail.techName"
|
||||
:team="jobDetail.team" :tech-options="jdTeamOptions" :can-onsite="jobDetail.canTeam" :team-loading="jobDetail.teamLoading"
|
||||
@assign-lead="jdAssignTech" @unassign-lead="jdUnassign" @updated="reloadOccupancy()">
|
||||
<template #right>
|
||||
<TicketStatusControl v-if="jobDetail.canTeam" doctype="Dispatch Job" :docname="jobDetail.name" :status="jobDetail.status" :scheduled-date="jobDetail.iso"
|
||||
@changed="p => { if (p && p.status) jobDetail.status = p.status; reloadOccupancy(); reloadPool && reloadPool() }" />
|
||||
</template>
|
||||
</JobTeamField>
|
||||
</q-card-section>
|
||||
<q-separator />
|
||||
<q-card-section class="jd-body scroll">
|
||||
<!-- Détails SIMPLES d'abord (carte/tracé GPS repliés : non requis pour lire le ticket) -->
|
||||
<!-- Détails SIMPLES d'abord. Le volet consomme désormais les MÊMES modules partagés que le détail ticket (IssueDetail). -->
|
||||
<div class="jd-meta">
|
||||
<div v-if="jobDetail.time"><q-icon name="schedule" size="16px" /> {{ jobDetail.time }}</div>
|
||||
<div v-if="jobDetail.address"><q-icon name="place" size="16px" /> {{ jobDetail.address }}</div>
|
||||
<div v-if="jdHasCoords" class="jd-geolinks">
|
||||
<a :href="jdMapsUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="satellite_alt" size="14px" /> Carte / satellite</a>
|
||||
<a :href="jdStreetViewUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="streetview" size="14px" /> Street View</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ajuster la durée estimée (comme « Suggérer ») → persiste duration_h -->
|
||||
<div class="jd-dur row items-center q-gutter-sm q-my-sm">
|
||||
<q-icon name="timer" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Durée estimée</span>
|
||||
<q-input dense outlined type="number" step="0.25" min="0" v-model.number="jobDetail.durH" style="width:96px" suffix="h" @change="jdSetDuration(jobDetail.durH)" @keyup.enter="jdSetDuration(jobDetail.durH)" />
|
||||
<span class="text-caption text-grey-5">≈ {{ fmtMin(Math.round((jobDetail.durH || 0) * 60)) }}</span>
|
||||
</div>
|
||||
<!-- Compétences REQUISES (dispatch auto) — LISTE : sélecteur/créateur en chips (autosuggest). Le tech doit les avoir TOUTES. 1re = principale. -->
|
||||
<div class="jd-skill row items-center q-gutter-sm q-mb-xs">
|
||||
<q-icon name="construction" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Compétences requises</span>
|
||||
<TagEditor :model-value="jobDetail.skills" :all-tags="tagCatalog" :get-color="getTagColor" :can-edit="false" placeholder="Ajouter une compétence requise…" style="min-width:220px;flex:1" @update:model-value="jdSetJobSkills" @create="onCreateRosterTag" />
|
||||
</div>
|
||||
<!-- Attribuer cette compétence à un tech précis → il sera matché au dispatch automatique. -->
|
||||
<!-- Durée estimée — module partagé. Il persiste duration_h ; le board conserve son override d'optimisation + le rappel « Ré-optimiser ». -->
|
||||
<DurationField :name="jobDetail.name" :dur-h="jobDetail.durH"
|
||||
@updated="p => { durOverride[jobDetail.name] = p.duration_h; jobDetail.durH = p.duration_h; $q.notify({ type: 'positive', message: 'Durée révisée : ' + p.duration_h + ' h — Ré-optimiser pour recalculer', timeout: 2500 }) }" />
|
||||
<!-- Compétences requises — module partagé (store hub /roster/job-skills). -->
|
||||
<RequiredSkillsField :name="jobDetail.name" :skills="jobDetail.skills" :all-tags="tagCatalog" :get-color="getTagColor"
|
||||
@updated="arr => { jobDetail.skills = arr; jobDetail.skill = arr[0] || ''; reloadPool && reloadPool() }" @create="onCreateRosterTag" />
|
||||
<!-- Attribuer cette compétence à un tech précis → matché au dispatch auto. BOARD-ONLY (nécessite la liste des techs). -->
|
||||
<div v-if="jobDetail.skill" class="row items-center q-gutter-xs q-mb-sm no-wrap">
|
||||
<span class="text-caption text-grey-6 no-wrap">Donner à :</span>
|
||||
<TechSelect v-model="jdSkillTech" :options="jdAllTechOpts" label="un technicien…" style="min-width:180px;flex:1" />
|
||||
<q-btn dense unelevated color="teal-7" icon="add" no-caps :disable="!jdSkillTech" :loading="jdSkillBusy" @click="jdGiveSkillToTech" label="Attribuer" />
|
||||
</div>
|
||||
<!-- Carte / tracé GPS : repliés par défaut -->
|
||||
<div class="q-mb-sm">
|
||||
<q-btn flat dense no-caps size="sm" color="grey-7" :icon="jdShowMap ? 'expand_less' : 'map'" :label="jdShowMap ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="jdShowMap = !jdShowMap" />
|
||||
</div>
|
||||
<div v-show="jdShowMap">
|
||||
<div ref="jdMapEl" class="jd-map"></div>
|
||||
<div v-if="kbCanTrack" class="jd-track-row"><q-toggle v-model="jdShowTrack" dense size="sm" color="warning" /><span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span><span class="text-caption text-grey-6">{{ jdTrackMsg }}</span></div>
|
||||
<div v-else class="text-caption text-grey-5 q-mb-sm">Tracé GPS réel : aujourd'hui / hier seulement (Traccar).</div>
|
||||
</div>
|
||||
<!-- Suivi terrain (géofencing GPS) : étapes façon suivi de colis — En route → Arrivé → Reparti, avec l'heure atteinte. -->
|
||||
<div v-if="jobDetail.geofence && jobDetail.geofence.state" class="jd-geo">
|
||||
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
|
||||
<div class="jd-geo-track">
|
||||
<div v-for="s in geoTimeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
|
||||
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
|
||||
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- « Équipe / renfort » fusionnée dans le champ d'assignation To/Cc en haut du volet (À = assigné · Cc = assistants). -->
|
||||
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">Commentaires / fil du billet<span v-if="jdMessages.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· plus récent en haut</span></div>
|
||||
<!-- Carte + tracé GPS (Traccar) — module partagé. Le board fournit les points de contexte à proximité + le jour affiché. -->
|
||||
<JobMapModule :name="jobDetail.name" :lat="jobDetail.lat" :lon="jobDetail.lon"
|
||||
:tech-id="jobDetail.techId" :iso="(kanbanDay && kanbanDay.iso) || jobDetail.iso" :can-track="kbCanTrack" :today-iso="nowET.iso" :nearby="jdNearby()" />
|
||||
<!-- Suivi terrain (géofencing) — module partagé (objet déjà chargé dans openJobDetail). -->
|
||||
<GeofenceTimeline :geofence="jobDetail.geofence" />
|
||||
<!-- Fil du billet — module partagé. Spinner board conservé pendant le chargement (évite un double fetch dans le module). -->
|
||||
<div v-if="jobDetail.loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
|
||||
<template v-else-if="jdMessages.length">
|
||||
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ jdMessages.length }} message(s){{ jobDetail.thread.status ? ' · ' + jobDetail.thread.status : '' }}</div>
|
||||
<div v-for="(m, mi) in jdMessages" :key="mi" class="de-msg" :class="{ 'de-msg-latest': mi === 0 }">
|
||||
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><span v-if="mi === 0 && jdMessages.length > 1" class="de-msg-badge">dernier</span></div>
|
||||
<div class="de-msg-txt">{{ m.text }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Repli : corps brut du billet UNIQUEMENT s'il n'y a aucun message dans le fil (sinon = doublon du 1er commentaire) -->
|
||||
<div v-else-if="jobDetail.detail" class="jd-detail">{{ jobDetail.detail }}</div>
|
||||
<div v-else class="text-grey-6 q-pa-md text-center">{{ jobDetail.lid ? ((jobDetail.thread && jobDetail.thread.error) ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
|
||||
<!-- Répondre au fil depuis la job — même geste que le panneau ticket + l'app terrain. Note INTERNE par défaut ; au client si coché. -->
|
||||
<div v-if="jobDetail.lid || jobDetail.name" class="q-mt-sm">
|
||||
<q-input v-model="jdReply" dense outlined type="textarea" autogrow placeholder="Écrire une note / réponse…" :input-style="{ minHeight: '48px' }" @keydown.ctrl.enter="sendJdReply" @keydown.meta.enter="sendJdReply" />
|
||||
<div class="row items-center q-mt-xs">
|
||||
<q-checkbox v-if="jobDetail.lid" v-model="jdReplyPublic" dense size="sm" label="Envoyer aussi au client" />
|
||||
<q-space />
|
||||
<q-btn dense unelevated color="primary" no-caps icon="send" :label="jdReplyPublic ? 'Répondre au client' : 'Note interne'" :disable="!jdReply.trim()" :loading="jdReplySending" @click="sendJdReply" />
|
||||
</div>
|
||||
</div>
|
||||
<JobThread v-else :thread="jobDetail.thread" :detail="jobDetail.detail" :lid="jobDetail.lid" />
|
||||
<!-- Répondre au fil — module partagé. Recharge le fil du volet après envoi. -->
|
||||
<JobReplyBox :name="jobDetail.name" :lid="jobDetail.lid" @sent="reloadJdThread" />
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
|
@ -1523,6 +1388,36 @@
|
|||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Job « bouche-trou » (filler) : job générique de priorité STANDARD assigné → occupe la journée ⇒ retire le tech du dispatch. Optionnel : génère un ticket. -->
|
||||
<q-dialog v-model="fillerDlg.open">
|
||||
<q-card style="min-width:340px;max-width:96vw">
|
||||
<q-card-section class="row items-center q-pb-none">
|
||||
<q-icon name="block" color="deep-orange-6" size="22px" class="q-mr-sm" />
|
||||
<div class="text-subtitle1 text-weight-bold">Bloquer / job générique</div>
|
||||
<q-space /><q-btn flat round dense icon="close" v-close-popup />
|
||||
</q-card-section>
|
||||
<q-card-section class="q-gutter-sm">
|
||||
<div class="text-body2"><b>{{ fillerDlg.tech && fillerDlg.tech.name }}</b> <span class="text-grey-6">— occupé toute la période ⇒ retiré du dispatch régulier</span></div>
|
||||
<q-input dense outlined v-model="fillerDlg.subject" label="Intitulé" placeholder="Ex. Formation · Réunion · Entretien véhicule" autofocus />
|
||||
<div class="row items-center q-gutter-sm">
|
||||
<q-toggle v-model="fillerDlg.fullDay" dense size="sm" color="deep-orange-6" label="Journée complète" @update:model-value="onFillerFullDay" />
|
||||
<q-input class="col" dense outlined type="date" v-model="fillerDlg.date" label="Date" />
|
||||
</div>
|
||||
<div class="row q-col-gutter-sm">
|
||||
<q-input class="col-4" dense outlined type="time" v-model="fillerDlg.start" label="Début" :disable="fillerDlg.fullDay" />
|
||||
<q-input class="col-4" dense outlined type="number" v-model.number="fillerDlg.dur" label="Durée (h)" min="0.5" step="0.5" :disable="fillerDlg.fullDay" />
|
||||
<q-select class="col-4" dense outlined emit-value map-options v-model="fillerDlg.priority" :options="POOL_PRIOS" option-value="value" option-label="label" label="Priorité" />
|
||||
</div>
|
||||
<q-input dense outlined v-model="fillerDlg.job_type" label="Type" placeholder="Interne" />
|
||||
<q-toggle v-model="fillerDlg.createTicket" dense size="sm" color="primary" label="Générer un ticket lié" />
|
||||
</q-card-section>
|
||||
<q-card-actions align="right" class="q-px-md q-pb-md">
|
||||
<q-btn flat no-caps label="Annuler" color="grey-7" v-close-popup />
|
||||
<q-btn unelevated no-caps color="deep-orange-6" icon="add" label="Créer" :loading="fillerDlg.busy" :disable="!fillerDlg.subject" @click="doFiller" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
|
||||
<!-- Drop d'une job sur un tech SANS quart : créer un quart (durée) ou marquer absent (durée + retour) -->
|
||||
<q-dialog v-model="dropAsk.open">
|
||||
<q-card style="min-width:380px;max-width:440px">
|
||||
|
|
@ -1677,7 +1572,8 @@
|
|||
:shifts="menuCellShifts" :is-garde="menuIsGarde" :is-absent="menuIsAbsent"
|
||||
:clipboard-count="cellClipboard.length" :initial-range="menuRange"
|
||||
@window="e => applyWindow(e.min, e.max)" @toggle-garde="toggleGardeMenu" @toggle-absent="openAbsDialog"
|
||||
@remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu" />
|
||||
@remove-shift="removeShiftFromMenu" @clear="clearOne" @copy="copyFromMenu" @paste="pasteFromMenu"
|
||||
@filler="openFillerFromMenu" />
|
||||
</q-menu>
|
||||
|
||||
<!-- Éditeur de JOURNÉE (clic sur le progressbar) : timeline + réordonner par drag-drop + retirer un job -->
|
||||
|
|
@ -1889,7 +1785,7 @@
|
|||
* 10. Chargement & solveur ................. loadBase/loadWeek/loadStats · doGenerate/doPublish
|
||||
* 11. Helpers date/temps/couleur .......... iso/hToNum/numToTime · occColor/todColor/getTagColor
|
||||
*/
|
||||
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick, provide } from 'vue'
|
||||
import { useRoute } from 'vue-router' // deep-link ?day=&skill= (tableau de bord, slot-finder)
|
||||
// Icônes de rôle monochromes outline (Material Symbols, style « une couleur » demandé) : échelle = installation.
|
||||
import { symOutlinedToolsLadder, symOutlinedHeadsetMic, symOutlinedHandyman } from '@quasar/extras/material-symbols-outlined'
|
||||
|
|
@ -1908,6 +1804,14 @@ import { useSla, SLA_BADGE } from 'src/composables/useSla' // SLA existant (poli
|
|||
import { useAuthStore } from 'src/stores/auth' // email de l'agent (X-Authentik-Email) pour l'envoi de réponses client
|
||||
import TechSelect from 'src/components/shared/TechSelect.vue'
|
||||
import AssignmentField from 'src/components/shared/AssignmentField.vue'
|
||||
// Modules détail on-site/dispatch PARTAGÉS (mêmes composants que IssueDetail) — le volet job les consomme désormais.
|
||||
import JobMapModule from 'src/components/shared/detail-sections/modules/JobMapModule.vue'
|
||||
import GeofenceTimeline from 'src/components/shared/detail-sections/modules/GeofenceTimeline.vue'
|
||||
import DurationField from 'src/components/shared/detail-sections/modules/DurationField.vue'
|
||||
import RequiredSkillsField from 'src/components/shared/detail-sections/modules/RequiredSkillsField.vue'
|
||||
import JobTeamField from 'src/components/shared/detail-sections/modules/JobTeamField.vue'
|
||||
import JobThread from 'src/components/shared/detail-sections/modules/JobThread.vue'
|
||||
import JobReplyBox from 'src/components/shared/detail-sections/modules/JobReplyBox.vue'
|
||||
import LeaveDialog from 'src/components/planif/LeaveDialog.vue' // Congés & disponibilités (extrait — décomposition #4)
|
||||
import TechSyncDialog from 'src/components/planif/TechSyncDialog.vue' // Synchroniser les techniciens (extrait — décomposition #4)
|
||||
import ShiftTypesDialog from 'src/components/planif/ShiftTypesDialog.vue' // Types de shift (extrait — décomposition #4)
|
||||
|
|
@ -1915,9 +1819,12 @@ import TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
|
|||
import HelpHint from 'src/components/shared/HelpHint.vue'
|
||||
import { skillIcon, skillSym, markerIcon } from 'src/composables/useSkillIcons' // SOURCE UNIQUE des icônes de compétences (réutilisée fiche client)
|
||||
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 SkillCadenceTable from 'src/components/planif/SkillCadenceTable.vue' // éditeur compétences+cadence PARTAGÉ (volet tech + outil Équipe)
|
||||
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 JobPool from 'src/components/planif/JobPool.vue' // pool « Jobs à assigner » UNIFIÉ (flottant · kanban · mobile) — remplace les 3 réimplémentations
|
||||
import { useJobPool } from 'src/composables/useJobPool' // cerveau filtre/tri/groupe/badges partagé par les 3 surfaces
|
||||
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)
|
||||
|
|
@ -2046,7 +1953,7 @@ function isHidden (id) { return hiddenTechs.value.includes(id) }
|
|||
function toggleHidden (id) { const i = hiddenTechs.value.indexOf(id); if (i >= 0) hiddenTechs.value.splice(i, 1); else hiddenTechs.value.push(id); localStorage.setItem('roster-hidden-techs-v1', JSON.stringify(hiddenTechs.value)) }
|
||||
const hiddenCount = computed(() => techs.value.filter(t => isHidden(t.id)).length)
|
||||
const showShiftEditor = ref(false) // v-model de <ShiftTypesDialog> (editTpls/newTpl/fonctions déplacés dans le composant)
|
||||
const showTeamEditor = ref(false); const editTechs = ref([])
|
||||
const showTeamEditor = ref(false); const editTechs = ref([]); const teamCost = reactive({})
|
||||
const notifySms = ref(false)
|
||||
// ── Notifier les techs de leur tournée (SMS/Email + lien token) : aperçu d'abord (compose sans envoi), puis envoi. ──
|
||||
const notifyDlg = reactive({ open: false, loading: false, sending: false, techs: [] })
|
||||
|
|
@ -2135,24 +2042,7 @@ const boardDate = computed({
|
|||
else if (v !== start.value) { start.value = v; onWeekChange() }
|
||||
},
|
||||
})
|
||||
const kanbanPool = computed(() => { const f = skillFilter.value; return (assignPanel.jobs || []).filter(j => !f.length || f.includes(j.required_skill)) })
|
||||
// Pool : recherche plein-texte + tri (priorité / ville / distance dépôt / compétence / durée), comme la fenêtre flottante.
|
||||
const kbSearch = ref(''); const kbSort = ref('prio')
|
||||
function kbCity (j) { if (j.municipalite) return j.municipalite; const m = String(j.address || '').match(/,\s*([^,]+?)\s*(?:,|$|\s[A-Z]\d[A-Z])/); return (m ? m[1] : '').trim() }
|
||||
const kanbanPoolView = computed(() => {
|
||||
let list = kanbanPool.value
|
||||
const q = kbSearch.value.trim().toLowerCase()
|
||||
if (q) list = list.filter(j => [j.subject, j.service_type, j.customer_name, j.address, j.required_skill].some(x => String(x || '').toLowerCase().includes(q)))
|
||||
const pr = (j) => ({ urgent: 0, high: 1, medium: 2, low: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
|
||||
const dep = depot.value; const dist = (j) => (dep && dep.lat != null && j.latitude != null) ? (haversineKm(dep.lat, dep.lon, +j.latitude, +j.longitude) ?? 9e9) : 9e9
|
||||
const s = kbSort.value
|
||||
return [...list].sort((a, b) =>
|
||||
s === 'city' ? (kbCity(a) || 'zz').localeCompare(kbCity(b) || 'zz') :
|
||||
s === 'dist' ? dist(a) - dist(b) :
|
||||
s === 'skill' ? String(a.required_skill || 'zz').localeCompare(String(b.required_skill || 'zz')) :
|
||||
s === 'dur' ? (b.est_min || 0) - (a.est_min || 0) :
|
||||
(pr(a) - pr(b) || (b.est_min || 0) - (a.est_min || 0)))
|
||||
})
|
||||
// Pool kanban → instance useJobPool unifiée (créée plus bas avec les autres surfaces).
|
||||
// Blocs d'une lane : depuis l'occupation BRUTE (occByTechDay = TOUS les jobs assignés ; robuste vs shift-gating qui
|
||||
// faisait « disparaître » la carte). Timés (start_h) à leur heure ; non-timés en flow ; inclut les miroirs « assist ».
|
||||
const KB_DAY_START = 8 // 8h AM (style Gaiia) : départ par défaut de la tournée
|
||||
|
|
@ -2215,7 +2105,6 @@ async function fetchKbMatrix (techId) {
|
|||
} catch (e) { /* repli haversine (géré dans kanbanLaneBlocks) */ } finally { _kbMatPending.delete(key) }
|
||||
}
|
||||
let _kbMatT = null // le watch déclencheur est enregistré PLUS BAS (après la déclaration de occByTechDay) pour éviter la zone morte temporelle
|
||||
const kbColor = (j) => j.skill ? getTagColor(j.skill) : (j.required_skill ? getTagColor(j.required_skill) : (legacyDeptColor(j.legacy_dept) || '#90a4ae'))
|
||||
async function reloadPool () { try { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { /* non bloquant */ } }
|
||||
// ── Création de job NATIVE OPS (#17) — Dispatch déprécié → tout depuis Planification. Réutilise UnifiedCreateModal (work-order). ──
|
||||
// Job OPS-natif = PAS de legacy_ticket_id → coexiste avec les jobs synchronisés de F (la sync F ne touche que les jobs à legacy_ticket_id). « Sync F transitoire » = coexistence, pas de push F pour l'instant.
|
||||
|
|
@ -2414,6 +2303,8 @@ async function sendJdReply () {
|
|||
} else { $q.notify({ type: 'negative', message: (r && r.error) || 'Échec de l\'envoi', timeout: 3000 }) }
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Échec de l\'envoi', timeout: 3000 }) } finally { jdReplySending.value = false }
|
||||
}
|
||||
// Recharge le fil du volet après un envoi via le module JobReplyBox (le module poste ; le volet possède jobDetail.thread).
|
||||
async function reloadJdThread () { if (jobDetail.lid) { try { jobDetail.thread = await roster.ticketThread(jobDetail.lid) } catch (e) { /* garde l'existant */ } } }
|
||||
async function jdAddAssistant () {
|
||||
const id = jobDetail.teamAdd; if (!id || !jobDetail.name) return
|
||||
const tech = (techs.value || []).find(t => t.id === id)
|
||||
|
|
@ -2681,6 +2572,44 @@ function openReserve (t) {
|
|||
const iso = mobileSelIso.value || kbSelIso.value || start.value || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
|
||||
Object.assign(resvDlg, { open: true, tech: { id: t.id, name: t.name }, date: iso, start: '08:00', dur: 2, reason: '', busy: false })
|
||||
}
|
||||
// Job « bouche-trou » (filler) : depuis le menu de cellule (clic case vide / clic droit). Pré-remplit tech+date+durée.
|
||||
// La durée « journée complète » = longueur du quart de la case s'il existe, sinon 8 h. Occupe l'occupation → hors dispatch.
|
||||
const fillerDlg = reactive({ open: false, tech: null, date: '', start: '08:00', dur: 8, fullDay: true, shiftH: 8, priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false })
|
||||
function shiftHoursOf (techId, iso) { // longueur cumulée des quarts de la case (0 si aucun)
|
||||
const occ = cellOcc(techId, iso)
|
||||
if (occ && occ.hasReg && occ.shiftE > occ.shiftS) return Math.round((occ.shiftE - occ.shiftS) * 10) / 10
|
||||
return 0
|
||||
}
|
||||
function openFillerFromMenu () {
|
||||
if (!menu.tech || !menu.day) return
|
||||
const sh = shiftHoursOf(menu.tech.id, menu.day.iso)
|
||||
const occ = cellOcc(menu.tech.id, menu.day.iso)
|
||||
const startH = (occ && occ.hasReg && occ.shiftS != null) ? occ.shiftS : 8
|
||||
Object.assign(fillerDlg, {
|
||||
open: true, tech: { id: menu.tech.id, name: menu.tech.name }, date: menu.day.iso,
|
||||
start: numToTime(startH), dur: sh || 8, fullDay: true, shiftH: sh || 8,
|
||||
priority: 'medium', job_type: 'Interne', subject: '', createTicket: true, busy: false,
|
||||
})
|
||||
menu.show = false
|
||||
}
|
||||
function onFillerFullDay (v) { if (v) { fillerDlg.dur = fillerDlg.shiftH || 8 } } // journée complète → durée = quart
|
||||
async function doFiller () {
|
||||
if (!fillerDlg.tech || !fillerDlg.date || !fillerDlg.subject || fillerDlg.busy) return
|
||||
fillerDlg.busy = true
|
||||
try {
|
||||
const dur = fillerDlg.fullDay ? (fillerDlg.shiftH || 8) : (Number(fillerDlg.dur) || 8)
|
||||
const r = await roster.createFillerJob({
|
||||
tech: fillerDlg.tech.id, date: fillerDlg.date, start_time: fillerDlg.start, duration_h: dur,
|
||||
priority: fillerDlg.priority, job_type: fillerDlg.job_type || 'Interne', subject: fillerDlg.subject,
|
||||
create_ticket: fillerDlg.createTicket,
|
||||
})
|
||||
if (r && r.ok) {
|
||||
$q.notify({ type: 'positive', icon: 'block', message: fillerDlg.tech.name + ' — ' + dur + 'h bloquées' + (r.issue ? ' · ticket ' + r.issue : '') })
|
||||
fillerDlg.open = false
|
||||
try { await reloadOccupancy() } catch (e) {}
|
||||
} else $q.notify({ type: 'negative', message: (r && r.error) || 'Création impossible' })
|
||||
} catch (e) { $q.notify({ type: 'negative', message: 'Création impossible : ' + (e.message || e) }) } finally { fillerDlg.busy = false }
|
||||
}
|
||||
async function doReserve () {
|
||||
if (!resvDlg.tech || !resvDlg.date || resvDlg.busy) return
|
||||
resvDlg.busy = true
|
||||
|
|
@ -2886,6 +2815,14 @@ const draggingJobName = ref(null); const dropCell = ref(null); const dragHours =
|
|||
const selectedJobs = reactive({}) // jobName → true
|
||||
const dropPreview = reactive({ key: null, addH: 0 })
|
||||
const draggingSet = reactive(new Set()); let _dragGhost = null // jobs en cours de glissé (source estompée) + fantôme custom
|
||||
// ── Pools « Jobs à assigner » UNIFIÉS : une instance useJobPool par surface (détenues ici → la carte du panneau lit floatingPool.filteredJobs).
|
||||
// Créées TÔT (avant assignLocations / le watch de la carte) ; les deps sont des fonctions hoistées, les closures (depot, mobileSelDay) sont paresseuses. ──
|
||||
const _poolDist = (j) => { const dep = depot.value; return (dep && dep.lat != null && j.latitude != null) ? (haversineKm(dep.lat, dep.lon, +j.latitude, +j.longitude) ?? 9e9) : 9e9 }
|
||||
const jpDeps = { jobCity, jobLocKey: _jobLocKey, todayISO, fmtDueLabel }
|
||||
const floatingPool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'floating' })
|
||||
const kanbanPool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'docked', distOf: _poolDist })
|
||||
const mobilePool = useJobPool(() => assignPanel.jobs, jpDeps, { variant: 'mobile', selectedDay: () => mobileSelDay.value })
|
||||
const jpShowMap = computed(() => assignPanel.showMap)
|
||||
async function openAssignPanel () { assignPanel.open = true; assignPanel.loading = true; for (const k in selectedJobs) delete selectedJobs[k]; try { assignPanel.jobs = capPickups((await roster.unassignedJobs()).jobs) } catch (e) { err(e) } finally { assignPanel.loading = false } }
|
||||
|
||||
// Write-back legacy : aperçu (dryRun, 0 écriture) → confirmation → écrit ticket.assign_to dans osTicket.
|
||||
|
|
@ -2903,9 +2840,6 @@ async function removeFromPush (s) {
|
|||
$q.notify({ type: 'info', message: 'Job désassigné (retour au pool) — exclu du legacy', timeout: 2200 })
|
||||
} catch (e) { err(e) } finally { legacyPush.applying = false }
|
||||
}
|
||||
const assignSort = ref('date') // défaut = date DUE (facilite le dispatch : aujourd'hui/en retard d'abord). Autres : group | skill | city | priority
|
||||
const assignSortDir = ref('asc') // sens du tri (asc/desc) — surtout pour la date
|
||||
const ASSIGN_PRIO = { urgent: 0, high: 1, medium: 2, low: 3 }
|
||||
function jobCity (j) {
|
||||
if (j.municipalite) return j.municipalite // municipalité CANONIQUE résolue côté hub (regroupe Ste-Clotilde / ste-clotilde / … en 1 groupe)
|
||||
const a = String(j.location_label || j.service_location || '')
|
||||
|
|
@ -2914,48 +2848,17 @@ function jobCity (j) {
|
|||
const subj = String(j.subject || ''); if (subj.includes('|')) return subj.split('|')[0].trim() // sujets legacy « Ville | Nom »
|
||||
return parts[0] || 'Sans ville'
|
||||
}
|
||||
// Chips-filtres par compétence/type (installation, réparation, tv…) dans le panneau d'assignation.
|
||||
const assignTypeFilter = ref([]) // types sélectionnés (vide = tous)
|
||||
const assignTypes = computed(() => { const m = {}; for (const j of assignPanel.jobs) { const k = j.required_skill || 'autre'; m[k] = (m[k] || 0) + 1 } return Object.entries(m).map(([k, n]) => ({ k, n })).sort((a, b) => b.n - a.n) })
|
||||
// (Chips compétence + date du panneau → fusionnés dans useJobPool : floatingPool.skillChips / .dateChips / .skillFilter / .dateFilter.)
|
||||
// Chips « Trouver un créneau » : 4 compétences principales (les + fréquentes du pool, repli liste cœur) + durée PAR DÉFAUT par skill.
|
||||
const SLOT_SKILL_DUR = { installation: 2, 'réparation': 1, reparation: 1, tv: 1, 'télévision': 1, television: 1, 'téléphonie': 0.5, telephone: 0.5, 'téléphone': 0.5, 'sans-fil': 1.5, fibre: 1.5, support: 0.5 }
|
||||
const slotSkills = computed(() => {
|
||||
const top = (assignTypes.value || []).map(t => t.k).filter(k => k && k.toLowerCase() !== 'autre').slice(0, 4)
|
||||
const top = (floatingPool.skillChips.value || []).map(t => t.k).filter(k => k && k.toLowerCase() !== 'autre').slice(0, 4)
|
||||
return (top.length ? top : ['installation', 'réparation', 'tv']).map(k => ({ skill: k, dur: SLOT_SKILL_DUR[k.toLowerCase()] || 1, color: getTagColor(k) }))
|
||||
})
|
||||
// Filtre par DATE DUE (chips) — « n'afficher que les jobs d'une date précise »
|
||||
const assignDateFilter = ref([]) // isos retenus (vide = toutes les dates)
|
||||
const assignDates = computed(() => {
|
||||
const t = todayISO(); const m = {}
|
||||
for (const j of assignPanel.jobs) { const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'; m[d] = (m[d] || 0) + 1 }
|
||||
// Toutes les dates PASSÉES → UN SEUL chip « ⏰ en retard » (déclutter). Les jours futurs + « Sans date » restent séparés.
|
||||
const out = []; let overdueN = 0
|
||||
for (const [iso, n] of Object.entries(m)) {
|
||||
if (iso !== 'Sans date' && iso < t) { overdueN += n; continue }
|
||||
out.push({ iso, n, label: iso === 'Sans date' ? 'Sans date' : fmtDueLabel(iso), overdue: false })
|
||||
}
|
||||
out.sort((a, b) => String(a.iso).localeCompare(String(b.iso)))
|
||||
if (overdueN) out.unshift({ iso: '__overdue__', n: overdueN, label: '⏰ en retard', overdue: true })
|
||||
return out
|
||||
})
|
||||
function toggleAssignDate (iso) { const s = new Set(assignDateFilter.value); s.has(iso) ? s.delete(iso) : s.add(iso); assignDateFilter.value = [...s] }
|
||||
// Depuis l'aperçu « jobs sur la colonne du jour » → ouvre le panneau « À assigner » filtré sur ce jour.
|
||||
async function openDayInPanel (iso) { await openAssignPanel(); assignSort.value = 'date'; assignDateFilter.value = [iso] }
|
||||
// Un job correspond-il aux CHIPS de date sélectionnées ? (vide = toutes). '__overdue__' = toutes dates passées.
|
||||
// Partagé par le filtre d'affichage ET le dispatch auto (« Suggérer n'utilise que les jobs des jours cochés »).
|
||||
function jobMatchesDateChips (j) {
|
||||
const df = assignDateFilter.value; if (!df.length) return true
|
||||
const ds = new Set(df); const t = todayISO()
|
||||
const d = (j.scheduled_date && j.scheduled_date !== 'Sans date') ? j.scheduled_date : 'Sans date'
|
||||
return ds.has(d) || (ds.has('__overdue__') && d !== 'Sans date' && d < t)
|
||||
}
|
||||
const assignJobsFiltered = computed(() => {
|
||||
let arr = assignPanel.jobs
|
||||
const f = assignTypeFilter.value; if (f.length) { const set = new Set(f); arr = arr.filter(j => set.has(j.required_skill || 'autre')) }
|
||||
if (assignDateFilter.value.length) arr = arr.filter(jobMatchesDateChips)
|
||||
return arr
|
||||
})
|
||||
function toggleAssignType (k) { const i = assignTypeFilter.value.indexOf(k); if (i >= 0) assignTypeFilter.value.splice(i, 1); else assignTypeFilter.value.push(k) }
|
||||
// Jour PRÉCIS sélectionné → on borne au jour (chip date) ET on groupe par VILLE par défaut (la date étant déjà fixée, le secteur est l'axe utile pour dispatcher une tournée).
|
||||
async function openDayInPanel (iso) { await openAssignPanel(); floatingPool.sort.value = 'city'; floatingPool.dateFilter.value = [iso] }
|
||||
// (Filtre date+compétence-par-adresse, info d'adresse et badges → fusionnés dans useJobPool : floatingPool.filteredJobs / .jobAddrBadge / .jobAssocOnly.)
|
||||
// Date DUE (≠ date de création) : étiquette relative pour grouper/afficher (En retard / Aujourd'hui / future).
|
||||
function isOverdue (d) { return !!d && d !== 'Sans date' && d < todayISO() }
|
||||
function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due'; const t = todayISO(); if (d < t) return d + ' ⏰ en retard'; if (d === t) return "Aujourd'hui"; return d }
|
||||
|
|
@ -2963,27 +2866,7 @@ function fmtDueLabel (d) { if (!d || d === 'Sans date') return 'Sans date due';
|
|||
function capSegClass (s) { if (!s || s.cap <= 0) return 'cap-none'; if (s.free <= 0) return 'cap-full'; if (s.free < 1.5) return 'cap-low'; return 'cap-ok' }
|
||||
const capFmt = (h) => (h === 0 ? '0' : (Number.isInteger(h) ? String(h) : h.toFixed(1)))
|
||||
const fmtMin = (m) => { m = Math.round(Number(m) || 0); const h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? String(mm).padStart(2, '0') : '')) : (mm + 'min') }
|
||||
const assignGroups = computed(() => {
|
||||
const jobs = assignJobsFiltered.value
|
||||
if (assignSort.value === 'group') { // défaut : groupe parent-enfant (installation avant activation…), ordonné par step_order
|
||||
const g = {}; for (const j of jobs) { const k = j.parent_job || j.name; (g[k] = g[k] || []).push(j) }
|
||||
return Object.keys(g).map(k => ({ key: k, label: null, jobs: g[k].slice().sort((a, b) => (a.step_order || 0) - (b.step_order || 0)) }))
|
||||
}
|
||||
const keyOf = j => assignSort.value === 'skill' ? (j.required_skill || 'Sans compétence')
|
||||
: assignSort.value === 'city' ? jobCity(j)
|
||||
: assignSort.value === 'priority' ? (j.priority || 'low')
|
||||
: (j.scheduled_date || 'Sans date')
|
||||
const labelOf = k => assignSort.value === 'priority' ? (({ urgent: '🔴 Urgent', high: '🟠 Élevée', medium: '🔵 Moyenne', low: '⚪ Basse' })[k] || k) : assignSort.value === 'date' ? '📅 ' + fmtDueLabel(k) : k
|
||||
const g = {}; for (const j of jobs) { const k = keyOf(j); (g[k] = g[k] || []).push(j) }
|
||||
const dir = assignSortDir.value === 'desc' ? -1 : 1
|
||||
const keys = Object.keys(g).sort((a, b) => (assignSort.value === 'priority' ? (ASSIGN_PRIO[a] ?? 9) - (ASSIGN_PRIO[b] ?? 9) : a.localeCompare(b)) * dir)
|
||||
return keys.map(k => ({ key: k, label: labelOf(k), jobs: g[k] }))
|
||||
})
|
||||
// Repli des groupes — « tout replier, n'afficher que certains jobs groupés par date »
|
||||
const assignCollapsed = ref(new Set())
|
||||
function toggleCollapse (key) { const s = new Set(assignCollapsed.value); s.has(key) ? s.delete(key) : s.add(key); assignCollapsed.value = s }
|
||||
const allCollapsed = computed(() => { const gs = assignGroups.value; return gs.length > 0 && gs.every(g => assignCollapsed.value.has(g.key)) })
|
||||
function toggleCollapseAll () { assignCollapsed.value = allCollapsed.value ? new Set() : new Set(assignGroups.value.map(g => g.key)) }
|
||||
// (assignGroups / repli des groupes → fusionnés dans useJobPool : floatingPool.groups / floatingPool.collapsed.)
|
||||
function toggleGroupSelAll (grp) { const anyOff = grp.jobs.some(j => !selectedJobs[j.name]); grp.jobs.forEach(j => { if (anyOff) selectedJobs[j.name] = true; else delete selectedJobs[j.name] }); if (assignPanel.showMap) refreshAssignMap() }
|
||||
// Terrain vs à distance : l'activation / config / netadmin ne va PAS à un tech sur site (heuristique skill + type/sujet).
|
||||
function jobIsOnsite (j) {
|
||||
|
|
@ -3074,7 +2957,7 @@ function _jobLocKey (j) { const a = _normAddr(j.address); if (a) return a; retur
|
|||
// Localisations des jobs géolocalisés : 1 LETTRE (A, B, C…) par ADRESSE = 1 pin (couleur = compétence). Lie la liste aux pins.
|
||||
const assignLocations = computed(() => {
|
||||
const by = {}; const order = []
|
||||
for (const j of assignJobsFiltered.value) { // carte = MÊME filtre que la liste (date sélectionnée + compétence)
|
||||
for (const j of floatingPool.filteredJobs.value) { // carte = MÊME filtre que la liste (date sélectionnée + compétence)
|
||||
if (!_hasJobLL(j)) continue
|
||||
const k = (Math.round(+j.latitude * 1e5) / 1e5) + ',' + (Math.round(+j.longitude * 1e5) / 1e5)
|
||||
if (!by[k]) { by[k] = { lat: +j.latitude, lon: +j.longitude, jobs: [] }; order.push(k) }
|
||||
|
|
@ -3358,12 +3241,12 @@ async function openLegacyBlock (b) {
|
|||
// Synchroniser les techniciens → extrait dans components/planif/TechSyncDialog.vue (état + fonctions déplacés ; @applied → loadBase).
|
||||
const showTechSync = ref(false)
|
||||
const _hasJobLL = (j) => j && j.latitude != null && j.longitude != null && isFinite(+j.latitude) && isFinite(+j.longitude) && (Math.abs(+j.latitude) > 0.01 || Math.abs(+j.longitude) > 0.01)
|
||||
const assignNoCoord = computed(() => assignJobsFiltered.value.filter(j => !_hasJobLL(j)).length) // « sans coords » = dans le filtre courant (cohérent avec la carte)
|
||||
const assignNoCoord = computed(() => floatingPool.filteredJobs.value.filter(j => !_hasJobLL(j)).length) // « sans coords » = dans le filtre courant (cohérent avec la carte)
|
||||
// LOT (pool « à assigner ») : faire correspondre l'adresse de chaque job sans coords à la base RQA → GPS, écrit
|
||||
// sur le Dispatch Job + en mémoire (j.latitude/longitude) → les jobs apparaissent sur la carte du pool. Ambigus = à la main.
|
||||
async function batchLocatePool () {
|
||||
// MÊME périmètre que le badge assignNoCoord (jobs sans coords du filtre courant) → plus d'incohérence badge/notification.
|
||||
const coordless = assignJobsFiltered.value.filter(j => !_hasJobLL(j))
|
||||
const coordless = floatingPool.filteredJobs.value.filter(j => !_hasJobLL(j))
|
||||
if (!coordless.length) { $q.notify({ type: 'positive', message: 'Tous les jobs à assigner ont déjà des coordonnées 🎉', timeout: 2000 }); return }
|
||||
const targets = coordless.filter(j => String(j.address || j.location_label || '').trim().length >= 3)
|
||||
if (!targets.length) { // sans coords MAIS sans adresse exploitable → pas d'auto-localisation possible : on ouvre le sélecteur manuel sur le 1er
|
||||
|
|
@ -3548,7 +3431,7 @@ async function fetchTechRoute (home, stops) {
|
|||
} catch (e) { return null }
|
||||
}
|
||||
watch(() => assignPanel.showMap, (on) => { if (on) { if (assignPanel.h < 600) assignPanel.h = 640; nextTick(initAssignMap) } else destroyAssignMap() })
|
||||
watch(assignJobsFiltered, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs
|
||||
watch(floatingPool.filteredJobs, () => { if (assignPanel.showMap) nextTick(() => { _assignMap ? refreshAssignMap() : initAssignMap() }) }) // carte suit le filtre (date/compétence) + les jobs
|
||||
watch(selectedJobs, () => { if (assignPanel.showMap && _assignMap) refreshAssignMap() }) // surlignage doré des pins/amas suit la sélection
|
||||
watch(() => assignPanel.open, (o) => { if (!o) destroyAssignMap() })
|
||||
|
||||
|
|
@ -4520,7 +4403,7 @@ async function quickAssignSelection (tech) {
|
|||
}
|
||||
// Sélectionne / désélectionne TOUS les jobs d'un secteur (municipalité canonique) — depuis l'en-tête de secteur du pool.
|
||||
function selectAllSector (city) {
|
||||
const jobs = poolJobs.value.filter(j => (jobCity(j) || 'Sans secteur') === city && j.status !== 'On Hold')
|
||||
const jobs = mobilePool.filteredJobs.value.filter(j => (jobCity(j) || 'Sans secteur') === city && j.status !== 'On Hold')
|
||||
const allSel = jobs.length && jobs.every(j => selectedJobs[j.name])
|
||||
jobs.forEach(j => { if (allSel) delete selectedJobs[j.name]; else selectedJobs[j.name] = true })
|
||||
if (assignPanel.showMap) refreshAssignMap()
|
||||
|
|
@ -4565,6 +4448,7 @@ function onRoutesStopClick (stop, rid) { // onglet TOURNÉES : l'arrêt porte le
|
|||
const j = stop.job || {}
|
||||
openJobDetail({
|
||||
name: stop.name || j.name, subject: stop.subject || j.subject, customer: j.customer || j.customer_name || '',
|
||||
customer_id: j.customer_id || '', // ← lien vers la fiche client depuis un job ouvert sur la carte (était perdu : reconstruit sans customer_id)
|
||||
address: j.address || j.location_label || '', skill: j.skill || '', dur: j.dur,
|
||||
detail: j.legacy_detail || '', legacy_id: j.legacy_id || j.legacy_ticket_id || null, dept: j.legacy_dept || j.dept || '',
|
||||
lat: stop.lat, lon: stop.lon,
|
||||
|
|
@ -4742,7 +4626,7 @@ function buildSuggestion () {
|
|||
// En retard (date < fenêtre) ou sans date : NE PLUS balayer automatiquement sur le(s) jour(s) choisi(s).
|
||||
// Seulement si EXPLICITEMENT demandé : sélection lasso, ou chip « ⏰ en retard » / « Sans date » coché.
|
||||
// → sélectionner un seul jour ne dispatche QUE les jobs dus ce jour-là (retour terrain : les retards ne doivent pas s'inviter).
|
||||
const wantBacklog = useSel || (assignDateFilter.value || []).includes('__overdue__') || (assignDateFilter.value || []).includes('Sans date')
|
||||
const wantBacklog = useSel || (floatingPool.dateFilter.value || []).includes('__overdue__') || (floatingPool.dateFilter.value || []).includes('Sans date')
|
||||
if (!wantBacklog) continue
|
||||
cand = win
|
||||
}
|
||||
|
|
@ -4854,7 +4738,7 @@ const suggestWindow = computed(() => {
|
|||
// Vue Tournées : la fenêtre = EXACTEMENT le jour sélectionné (autorité = la bande de dates), jamais les chips de date du pool.
|
||||
// → « Suggérer » ne balaie PAS les jobs en retard (date < jour choisi) sur ce jour ; seuls les jobs DUS ce jour-là sont répartis.
|
||||
if (boardView.value === 'routes' && routesDay.value) return [routesDay.value]
|
||||
const f = assignDateFilter.value || []
|
||||
const f = floatingPool.dateFilter.value || []
|
||||
if (!f.length) return [suggestDay.value || todayISO()] // aucun chip coché → sélecteur de jour du dialogue
|
||||
const days = new Set(f.filter(iso => iso !== '__overdue__' && iso !== 'Sans date')) // jours concrets cochés (auj./futur)
|
||||
if (f.includes('__overdue__') || f.includes('Sans date') || !days.size) days.add(todayISO()) // en retard / sans date → placés aujourd'hui
|
||||
|
|
@ -4866,13 +4750,13 @@ const suggestJobs = () => {
|
|||
if (selectedNames.value.length) return (assignPanel.jobs || []).filter(j => j.status !== 'On Hold' && selectedJobs[j.name]) // sélection lasso explicite
|
||||
// Vue Tournées : périmètre = EXACTEMENT la bande filtrée (jour sélectionné + compétence + secteur + priorité) → « ce que je filtre = ce que Suggérer répartit ».
|
||||
if (boardView.value === 'routes') return (routeDayUnassignedView.value || []).filter(j => j.status !== 'On Hold')
|
||||
let jobs = (assignJobsFiltered.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
|
||||
let jobs = (floatingPool.filteredJobs.value || []).filter(j => j.status !== 'On Hold') // pool tel qu'affiché (type + date)
|
||||
// Par DÉFAUT (aucun chip de date coché) : ne répartir QUE les jobs DATÉS sur le(s) jour(s) de la fenêtre — jamais les
|
||||
// jobs en retard/sans date/d'autres jours (souvent faits mais pas encore fermés) qui pollueraient la simulation.
|
||||
// (Pour dispatcher les retards, cocher explicitement le chip « ⏰ en retard » ou « Sans date ».)
|
||||
// Filtre appliqué à la SOURCE (couvre aussi le solveur ⚡ Optimiser qui prend suggestJobs()) : seuls les jobs
|
||||
// datés DANS la fenêtre passent, SAUF si l'utilisateur demande explicitement les retards/sans-date.
|
||||
const wantBacklog = (assignDateFilter.value || []).includes('__overdue__') || (assignDateFilter.value || []).includes('Sans date')
|
||||
const wantBacklog = (floatingPool.dateFilter.value || []).includes('__overdue__') || (floatingPool.dateFilter.value || []).includes('Sans date')
|
||||
if (!wantBacklog) {
|
||||
const win = new Set(suggestWindow.value)
|
||||
jobs = jobs.filter(j => j.scheduled_date && j.scheduled_date !== 'Sans date' && win.has(j.scheduled_date))
|
||||
|
|
@ -4881,19 +4765,19 @@ const suggestJobs = () => {
|
|||
}
|
||||
const suggestJobCount = computed(() => suggestJobs().length)
|
||||
// Y a-t-il un filtre restreignant le dispatch ? (lasso, chips de date ou de type)
|
||||
const suggestFiltered = computed(() => !!(selectedNames.value.length || assignDateFilter.value.length || assignTypeFilter.value.length))
|
||||
const suggestFiltered = computed(() => !!(selectedNames.value.length || floatingPool.dateFilter.value.length || floatingPool.skillFilter.value.length))
|
||||
// Libellé du périmètre : « sélectionnés » (lasso) · « des jours/types cochés » (chips) — décrit ce qui limite le jeu de jobs.
|
||||
const suggestScope = computed(() => {
|
||||
if (selectedNames.value.length) return 'sélectionnés'
|
||||
const parts = []
|
||||
if (assignDateFilter.value.length) parts.push('jours')
|
||||
if (assignTypeFilter.value.length) parts.push('types')
|
||||
if (floatingPool.dateFilter.value.length) parts.push('jours')
|
||||
if (floatingPool.skillFilter.value.length) parts.push('types')
|
||||
return parts.length ? ('des ' + parts.join(' + ') + ' cochés') : 'du pool'
|
||||
})
|
||||
const windowDayLabel = iso => iso === todayISO() ? "Aujourd'hui" : (iso === tomorrowISO() ? 'Demain' : (fmtDueLabel(iso) || iso))
|
||||
const suggestWindowLabel = computed(() => {
|
||||
const w = suggestWindow.value; if (!w.length) return '—'
|
||||
if ((assignDateFilter.value || []).length) return w.map(windowDayLabel).join(', ')
|
||||
if ((floatingPool.dateFilter.value || []).length) return w.map(windowDayLabel).join(', ')
|
||||
return w.length === 1 ? windowDayLabel(w[0]) : (windowDayLabel(w[0]) + ' + ' + windowDayLabel(w[w.length - 1]))
|
||||
})
|
||||
function suggestSelectTechs (which) { // 'all' | 'shift' | 'none'
|
||||
|
|
@ -5309,6 +5193,25 @@ const dayLivePositions = computed(() => {
|
|||
})
|
||||
watch(boardView, (v) => { if (v === 'routes') startLivePositions(); else stopLivePositions() }, { immediate: true })
|
||||
watch(routesDay, () => { for (const k in dayRouteMetrics) delete dayRouteMetrics[k]; hiddenRouteTechs.value = new Set() })
|
||||
// ── Tracé GPS RÉEL du jour : quand UNE seule tournée est affichée (une seule visible, ou isolée via les chips techs),
|
||||
// on superpose le parcours GPS réel du tech (Traccar) en orange, au 1er plan — pour comparer réel vs prévu. ──
|
||||
const soloRouteTech = computed(() => dayRoutes.value.length === 1 ? dayRoutes.value[0] : null)
|
||||
const showRouteTrack = ref(true)
|
||||
const routeTrack = ref(null); const routeTrackMsg = ref('')
|
||||
async function loadRouteTrack () {
|
||||
const t = soloRouteTech.value; const iso = routesDay.value
|
||||
if (boardView.value !== 'routes' || !t || !iso || !showRouteTrack.value) { routeTrack.value = null; routeTrackMsg.value = ''; return }
|
||||
routeTrackMsg.value = '…'
|
||||
const from = new Date(iso + 'T00:00:00').toISOString()
|
||||
const to = (iso === nowET.value.iso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
|
||||
try {
|
||||
const r = await roster.traccarTrack(t.id, from, to); const coords = (r && r.coords) || []
|
||||
if (coords.length < 2) { routeTrack.value = null; routeTrackMsg.value = 'aucun tracé / appareil'; return }
|
||||
routeTrack.value = { coords }
|
||||
routeTrackMsg.value = (r.km != null ? 'réel ' + r.km + ' km' : coords.length + ' pts') + (r.moving_min != null ? ' · ' + fmtMin(r.moving_min) : '')
|
||||
} catch (e) { routeTrack.value = null; routeTrackMsg.value = 'indispo' }
|
||||
}
|
||||
watch([soloRouteTech, routesDay, showRouteTrack, boardView], loadRouteTrack)
|
||||
watch(boardView, (v) => { if (v === 'routes' && (!routesDay.value || !dayList.value.some(d => d.iso === routesDay.value))) { const t = todayISO(); routesDay.value = ((dayList.value || []).find(d => d.iso >= t) || (dayList.value || [])[0] || {}).iso || t } }, { immediate: true })
|
||||
// Un tech « couvre » une file s'il possède TOUTES les compétences requises par ses jobs (compétences vides ignorées).
|
||||
const covers = (t, skills) => (skills || []).filter(Boolean).every(s => (t.skills || []).includes(s))
|
||||
|
|
@ -5378,32 +5281,7 @@ async function applySuggestion () {
|
|||
}
|
||||
// Mobile : assignation AU TOUCHER (le glisser HTML5 ne marche pas au doigt) + MULTI-sélection.
|
||||
// Tap un (ou plusieurs) job(s) du pool → tap un tech = assigne TOUTE la sélection. Réutilise selectedJobs/selectedNames.
|
||||
// Chips de filtre du pool (par compétence) — multi-sélection.
|
||||
const poolSkillSel = ref(new Set())
|
||||
const poolSort = ref('smart') // smart (défaut, jour sélectionné d'abord) | date | prio | dur
|
||||
const poolSortOpts = [{ value: 'smart', label: 'Pertinence' }, { value: 'sector', label: 'Secteur' }, { value: 'date', label: 'Date prévue' }, { value: 'prio', label: 'Priorité' }, { value: 'dur', label: 'Durée' }]
|
||||
const poolSkills = computed(() => { const m = {}; for (const j of (assignPanel.jobs || [])) { const s = j.required_skill; if (s) m[s] = (m[s] || 0) + 1 } return Object.entries(m).map(([skill, n]) => ({ skill, n })).sort((a, b) => b.n - a.n) })
|
||||
function togglePoolSkill (s) { const set = new Set(poolSkillSel.value); if (set.has(s)) set.delete(s); else set.add(s); poolSkillSel.value = set }
|
||||
// Pool : filtré par chips + trié selon poolSort. Défaut « Pertinence » = dû le JOUR SÉLECTIONNÉ → en retard → aujourd'hui → futur → sans date ; puis priorité ; puis date.
|
||||
const poolJobs = computed(() => {
|
||||
const t = todayISO(); const sel = mobileSelDay.value; const fs = poolSkillSel.value
|
||||
const pr = j => ({ urgent: 0, high: 1, élevée: 1, moyenne: 2, medium: 2, normal: 2, low: 3, basse: 3 }[String(j.priority || '').toLowerCase()] ?? 2)
|
||||
const dueRank = j => { const d = j.scheduled_date; if (d && d === sel) return 0; if (!d || d === 'Sans date') return 4; if (d < t) return 1; if (d === t) return 2; return 3 }
|
||||
const durMin = j => (+j.est_min || (+j.duration_h || 1) * 60)
|
||||
const byDate = (a, b) => { const da = a.scheduled_date && a.scheduled_date !== 'Sans date' ? a.scheduled_date : '9999-99-99'; const db = b.scheduled_date && b.scheduled_date !== 'Sans date' ? b.scheduled_date : '9999-99-99'; return String(da).localeCompare(String(db)) }
|
||||
let arr = assignPanel.jobs || []
|
||||
if (fs.size) arr = arr.filter(j => fs.has(j.required_skill))
|
||||
const secOf = j => jobCity(j) || 'zz Sans secteur'; const mrcOf = j => String(j.mrc || 'zz') // secteur=municipalité canonique ; ordonné par MRC pour regrouper les voisines
|
||||
const mode = poolSort.value
|
||||
const cmp = mode === 'date' ? (a, b) => byDate(a, b) || pr(a) - pr(b)
|
||||
: mode === 'prio' ? (a, b) => pr(a) - pr(b) || byDate(a, b)
|
||||
: mode === 'dur' ? (a, b) => durMin(b) - durMin(a) || dueRank(a) - dueRank(b)
|
||||
: mode === 'sector' ? (a, b) => mrcOf(a).localeCompare(mrcOf(b)) || secOf(a).localeCompare(secOf(b)) || dueRank(a) - dueRank(b) || pr(a) - pr(b)
|
||||
: (a, b) => dueRank(a) - dueRank(b) || pr(a) - pr(b) || byDate(a, b)
|
||||
return [...arr].sort(cmp)
|
||||
})
|
||||
// Comptes par secteur (municipalité) pour les en-têtes du regroupement « Secteur ».
|
||||
const poolSectorCounts = computed(() => { const m = {}; for (const j of poolJobs.value) { const k = jobCity(j) || 'Sans secteur'; const e = m[k] || (m[k] = { n: 0, mins: 0 }); e.n++; e.mins += (+j.est_min || (+j.duration_h || 1) * 60) } return m })
|
||||
// Filtre/tri/groupe du pool mobile → instance useJobPool `mobilePool` (créée avec les autres surfaces).
|
||||
// initials → composables/useFormatters (source unique ; 2 premiers mots · '?')
|
||||
function toggleSel (j) {
|
||||
if (j.status === 'On Hold') { $q.notify({ type: 'warning', message: 'En attente d\'une tâche précédente — non assignable', timeout: 2500 }); return }
|
||||
|
|
@ -5546,30 +5424,7 @@ function openJobSheet (j, opts) { jobSheet.job = j; snoozeDate.value = addDaysIS
|
|||
function onJobSheetHide () { const j = jobSheet.job; if (j && (sheetNoteText.value || '') !== (j.notes || '')) patchJob(j, { notes: sheetNoteText.value || '' }, 'Note enregistrée') }
|
||||
function openNote (j) { noteDialog.job = j; noteDialog.text = j.notes || ''; noteDialog.open = true }
|
||||
function saveNote () { const j = noteDialog.job; if (j) patchJob(j, { notes: noteDialog.text || '' }, 'Note enregistrée'); noteDialog.open = false }
|
||||
// Swipe MAISON, sûr pour le tap : on ne « glisse » qu'au drag horizontal franc (|dx|>10 et dominant) ; sinon le clic natif du bouton (toggleSel) reste intact.
|
||||
const swipe = reactive({ name: null, x0: 0, y0: 0, dx: 0, active: false })
|
||||
let swipeGuard = false
|
||||
function swipeStart (e, j) { if (e.pointerType === 'mouse' && e.button !== 0) return; swipe.name = j.name; swipe.x0 = e.clientX; swipe.y0 = e.clientY; swipe.dx = 0; swipe.active = false }
|
||||
function swipeMove (e) {
|
||||
if (swipe.name == null) return
|
||||
const dx = e.clientX - swipe.x0; const dy = e.clientY - swipe.y0
|
||||
if (!swipe.active) {
|
||||
if (Math.abs(dx) > 10 && Math.abs(dx) > Math.abs(dy) * 1.4) { swipe.active = true; try { e.currentTarget.setPointerCapture(e.pointerId) } catch (_) {} }
|
||||
else if (Math.abs(dy) > 10) { swipe.name = null; return } // défilement vertical → on lâche
|
||||
}
|
||||
if (swipe.active) { e.preventDefault(); swipe.dx = Math.max(-120, Math.min(120, dx)) }
|
||||
}
|
||||
function swipeEnd (e, j) {
|
||||
if (swipe.name == null) return
|
||||
const active = swipe.active; const dx = swipe.dx
|
||||
swipe.name = null; swipe.active = false; swipe.dx = 0
|
||||
if (!active) return // c'était un tap → onJobClick ouvre les DÉTAILS
|
||||
swipeGuard = true; setTimeout(() => { swipeGuard = false }, 380) // empêcher le clic « fantôme » post-glissement
|
||||
if (Math.abs(dx) > 55) toggleSel(j) // glissé G/D → SÉLECTIONNE pour assignation (la feuille de techs s'ouvre)
|
||||
}
|
||||
function swipeReset () { swipe.name = null; swipe.active = false; swipe.dx = 0 }
|
||||
function onJobClick (j) { if (swipeGuard) { swipeGuard = false; return } openJobSheet(j) } // TAP = détails (thread + options) ; SWIPE = sélectionner pour assignation
|
||||
function swipeStyle (j) { return (swipe.active && swipe.name === j.name) ? { transform: 'translateX(' + swipe.dx + 'px)' } : {} }
|
||||
// (Le swipe mobile — tap = feuille / glissé = sélectionner — vit désormais DANS JobPool.vue, variant mobile.)
|
||||
function clearSel () { for (const k in selectedJobs) delete selectedJobs[k] }
|
||||
// Tâches « sœurs » : même adresse + même client + même jour qu'une tâche sélectionnée (clé exigeant les 3 non vides), non sélectionnées.
|
||||
function jobBatchKey (j) {
|
||||
|
|
@ -5644,6 +5499,18 @@ function rawCellJobs (techId, iso) { const o = occByTechDay.value[techId + '|' +
|
|||
function offShiftJobs (techId, iso) { return (hasReg(techId, iso) || onGarde(techId, iso)) ? [] : rawCellJobs(techId, iso).filter(j => !j.cancelled) } // jobs RÉELS (annulés exclus) assignés un jour sans quart publié
|
||||
const offShiftWeekCount = computed(() => { let n = 0; for (const t of visibleTechs.value) for (const d of dayList.value) n += offShiftJobs(t.id, d.iso).length; return n }) // total jobs hors quart sur la période visible
|
||||
function prioColor (p) { return p === 'high' ? '#ef4444' : p === 'medium' ? '#f59e0b' : '#9e9e9e' }
|
||||
|
||||
// Contexte partagé fourni UNE fois → JobPool.vue l'injecte (selectedJobs/draggingSet gardent la même identité).
|
||||
provide('jobPoolCtx', {
|
||||
selectedJobs, draggingSet, selectedNames, flashJob, showMap: jpShowMap,
|
||||
onJobDragStart, onJobDragEnd,
|
||||
techsForJob, quickAssign, initials, jobTargetDay,
|
||||
setJobDate, setJobReqLevel, toggleUrgent, toggleAssignThread, openJobSheet, toggleSel, closeLegacyTicket, clearSel,
|
||||
toggleGroupSel, toggleGroupSelAll, groupSelected, groupLabel,
|
||||
selectAllSector, focusAssignJob,
|
||||
getTagColor, prioColor, fmtMin, jobCity, fmtDueLabel, isOverdue, jobIsOnsite, jobSlaBadge, legacyDeptColor, fmtDT, panelJobColor,
|
||||
todayISO, tomorrowISO,
|
||||
})
|
||||
// Aperçu en survol de drop : occupation projetée si on dépose la sélection ici.
|
||||
function isDropTarget (techId, iso) { return dropPreview.key === techId + '|' + iso }
|
||||
function projPct (techId, iso) { const o = cellOcc(techId, iso); if (!o || !o.bookableH) return null; return Math.round((o.usedH + dropPreview.addH) / o.bookableH * 100) }
|
||||
|
|
@ -5715,7 +5582,20 @@ function techRole (t) {
|
|||
}
|
||||
function roleIcon (t) { const r = techRole(t); return r ? ROLE_ICON[r] : null }
|
||||
function roleLabel (t) { const r = techRole(t); return r ? ROLE_LABEL[r] : '' }
|
||||
function openTeamEditor () { editTechs.value = techs.value.map(t => ({ id: t.id, name: t.name, group: t.group, skills: [...(t.skills || [])], efficiency: t.efficiency || 1, salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 })); showTeamEditor.value = true }
|
||||
// Opère sur les objets tech LIVE (techs.value) → SkillCadenceTable + les handlers de compétence/cadence persistent
|
||||
// directement (skill_levels/skill_eff/skills/efficiency). Le coût passe par un tampon (teamCost) sauvé au blur.
|
||||
function openTeamEditor () {
|
||||
editTechs.value = techs.value
|
||||
for (const t of techs.value) teamCost[t.id] = { salary: t.cost_salary_h || 0, charges: t.cost_charges_pct || 0, other: t.cost_other_h || 0 }
|
||||
showTeamEditor.value = true
|
||||
}
|
||||
function loadedCostRow (id) { const c = teamCost[id] || {}; return Math.round(((Number(c.salary) || 0) * (1 + (Number(c.charges) || 0) / 100) + (Number(c.other) || 0)) * 100) / 100 }
|
||||
async function saveCostRow (t) {
|
||||
const c = teamCost[t.id] || {}
|
||||
try { await roster.setTechCost(t.id, { salary: c.salary, charges: c.charges, other: c.other }); t.cost_salary_h = Number(c.salary) || 0; t.cost_charges_pct = Number(c.charges) || 0; t.cost_other_h = Number(c.other) || 0; t.cost_h = loadedCostRow(t.id); $q.notify({ type: 'positive', message: t.name + ' : ' + loadedCostRow(t.id) + ' $/h chargé' }) } catch (e) { err(e) }
|
||||
}
|
||||
// Cadence GLOBALE du tech (émise par SkillCadenceTable, en %) → facteur + persiste (défaut 100 % ↔ facteur 1).
|
||||
function setGlobalCadence (t, pct) { t.efficiency = factorFromPct(pct); saveEff(t) }
|
||||
// Éditeur d'équipe (table) : TagEditor émet un tableau → t.skills en tableau, puis persiste (l'API attend une CSV).
|
||||
function onTeamSkills (t, items) { t.skills = normSkillList(items); saveSkills(t) }
|
||||
async function saveSkills (t) { const csv = normSkillList(t.skills).join(','); try { await roster.setTechSkills(t.id, csv); const tt = techs.value.find(x => x.id === t.id); if (tt) tt.skills = csv ? csv.split(',') : []; $q.notify({ type: 'positive', message: t.name + ' : compétences enregistrées' }) } catch (e) { err(e) } }
|
||||
|
|
@ -6225,6 +6105,10 @@ onBeforeRouteLeave(async () => { await autosaveDraft() }) // #2 — flush le bro
|
|||
.assign-job.sel { border-color: #00897b; background: #e0f2f1; box-shadow: inset 0 0 0 1px #00897b; } /* sélectionné = à dispatcher */
|
||||
.assign-job.child { margin-left: 14px; border-left: 3px solid #b39ddb; }
|
||||
.assign-job.blocked { opacity: .65; }
|
||||
.assign-job.assoc { background: #fbfbfd; border-style: dashed; } /* affiché par co-localisation (compétence hors filtre) */
|
||||
.assign-linked { display: inline-flex; align-items: center; gap: 1px; color: #6d4c9f; background: #ede7f6; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
|
||||
.assign-addr { display: inline-flex; align-items: center; gap: 1px; color: #37474f; background: #eceff1; border-radius: 6px; padding: 0 4px; font-size: 9px; font-weight: 700; margin-right: 3px; }
|
||||
.assign-addr.multi { color: #fff; background: #7e57c2; } /* ≥2 compétences distinctes à l'adresse → tech polyvalent requis */
|
||||
.assign-job.job-flash { animation: jobflash 1.7s ease-out; }
|
||||
@keyframes jobflash { 0% { background: #fff59d; box-shadow: inset 0 0 0 2px #fbc02d; } 100% { background: #fafafa; box-shadow: none; } }
|
||||
.assign-sub { font-size: 10px; color: #888; margin-top: 1px; }
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@
|
|||
<template #chart>
|
||||
<div v-if="summary" class="ops-card q-mb-md" style="height:280px;position:relative"><canvas ref="chartCanvas"></canvas></div>
|
||||
</template>
|
||||
<template #body-cell-customer_name="props">
|
||||
<q-td :props="props"><router-link v-if="props.row.customer" :to="'/clients/' + encodeURIComponent(props.row.customer)" class="erp-link">{{ props.value }}</router-link><span v-else>{{ props.value }}</span></q-td>
|
||||
</template>
|
||||
<template #body-cell-total="props">
|
||||
<q-td :props="props"><span class="text-weight-bold text-negative">{{ formatMoney(props.value) }}</span></q-td>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -13,23 +13,38 @@
|
|||
</template>
|
||||
|
||||
<template #body-cell-name="props">
|
||||
<q-td :props="props"><a :href="erpLink('Sales Invoice', props.value)" target="_blank" class="text-primary">{{ props.value }}</a></q-td>
|
||||
<q-td :props="props"><a class="text-primary" style="cursor:pointer;text-decoration:underline" @click="openModal('Sales Invoice', props.value, props.value)">{{ props.value }}</a></q-td>
|
||||
</template>
|
||||
<template #body-cell-total="props">
|
||||
<q-td :props="props"><span :class="props.row.is_return ? 'text-negative' : 'text-weight-bold'">{{ formatMoney(props.value) }}</span></q-td>
|
||||
</template>
|
||||
<template #body-cell-customer_name="props">
|
||||
<q-td :props="props"><router-link v-if="props.row.customer" :to="'/clients/' + encodeURIComponent(props.row.customer)" class="erp-link">{{ props.value }}</router-link><span v-else>{{ props.value }}</span></q-td>
|
||||
</template>
|
||||
<template #body-cell-status="props">
|
||||
<q-td :props="props"><q-badge :color="statusColor(props.value)" :label="props.value" /></q-td>
|
||||
</template>
|
||||
</ReportScaffold>
|
||||
|
||||
<DetailModal
|
||||
v-model:open="modalOpen" :loading="modalLoading" :doctype="modalDoctype"
|
||||
:doc-name="modalDocName" :title="modalTitle" :doc="modalDoc"
|
||||
:comments="modalComments" :comms="modalComms" :files="modalFiles"
|
||||
:doc-fields="modalDocFields" :dispatch-jobs="modalDispatchJobs"
|
||||
@navigate="(dt, name, t) => openModal(dt, name, t)" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchSalesReport } from 'src/api/reports'
|
||||
import { formatMoney, formatDateShort, erpLink } from 'src/composables/useFormatters'
|
||||
import { formatMoney, formatDateShort } from 'src/composables/useFormatters'
|
||||
import { exportCsv } from 'src/composables/useCsvExport'
|
||||
import ReportScaffold from 'src/components/shared/ReportScaffold.vue'
|
||||
import DetailModal from 'src/components/shared/DetailModal.vue'
|
||||
import { useDetailModal } from 'src/composables/useDetailModal'
|
||||
|
||||
// Détail facture NATIF (ex-lien ERPNext desk) — ERPNext = base de données.
|
||||
const { modalOpen, modalLoading, modalDoctype, modalDocName, modalTitle, modalDoc, modalComments, modalComms, modalFiles, modalDocFields, modalDispatchJobs, openModal } = useDetailModal()
|
||||
|
||||
const now = new Date()
|
||||
const startDate = ref(new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10))
|
||||
|
|
|
|||
|
|
@ -150,10 +150,8 @@
|
|||
:class="{ 'user-card--selected': selectedUser?.pk === u.pk }"
|
||||
@click="selectUser(u)">
|
||||
<div class="row items-center no-wrap">
|
||||
<q-avatar size="36px" :color="u.is_active ? 'green-1' : 'grey-3'"
|
||||
:text-color="u.is_active ? 'green-8' : 'grey-6'" class="q-mr-sm">
|
||||
{{ initial(u) }}
|
||||
</q-avatar>
|
||||
<UserAvatar :email="u.email" :name="u.name || u.username" :size="36" class="q-mr-sm" />
|
||||
|
||||
<div class="col">
|
||||
<div class="text-weight-bold text-body2">{{ u.name || u.username }}</div>
|
||||
<div class="text-caption text-grey-6">{{ u.email }}</div>
|
||||
|
|
@ -175,18 +173,68 @@
|
|||
<!-- Selected user detail panel -->
|
||||
<q-slide-transition>
|
||||
<div v-if="selectedUser" class="user-detail-panel q-mt-md">
|
||||
<input ref="teamAvatarInput" type="file" accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
class="hidden" @change="onTeamAvatarFile">
|
||||
<div class="row items-center q-mb-md">
|
||||
<q-avatar size="42px" color="green-1" text-color="green-8" class="q-mr-md">
|
||||
{{ initial(selectedUser) }}
|
||||
</q-avatar>
|
||||
<!-- Avatar éditable par un admin (upload pour ce membre via ?as=). -->
|
||||
<div class="ua-edit q-mr-md" :class="{ 'ua-edit--can': can('manage_users') }"
|
||||
@click="can('manage_users') && pickTeamAvatar(selectedUser)">
|
||||
<UserAvatar :email="selectedUser.email" :name="selectedUser.name || selectedUser.username" :size="42" />
|
||||
<div v-if="can('manage_users')" class="ua-edit__cam">
|
||||
<q-icon name="photo_camera" size="12px" color="white" />
|
||||
</div>
|
||||
<q-tooltip v-if="can('manage_users')">Changer la photo</q-tooltip>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
|
||||
<!-- Nom complet éditable (source Authentik, affichée partout). -->
|
||||
<div v-if="!editingName" class="row items-center no-wrap">
|
||||
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
|
||||
<q-btn v-if="can('manage_users')" flat round dense size="sm" icon="edit"
|
||||
color="grey-6" class="q-ml-xs" @click="startEditName(selectedUser)">
|
||||
<q-tooltip>Modifier le nom complet</q-tooltip>
|
||||
</q-btn>
|
||||
</div>
|
||||
<div v-else class="row items-center no-wrap q-gutter-xs" style="max-width:420px">
|
||||
<q-input v-model="nameDraft" dense outlined autofocus class="col"
|
||||
:disable="savingName" @keyup.enter="commitEditName(selectedUser)"
|
||||
@keyup.esc="editingName = false" />
|
||||
<q-btn round dense unelevated color="primary" icon="check" :loading="savingName"
|
||||
@click="commitEditName(selectedUser)" />
|
||||
<q-btn round dense flat icon="close" :disable="savingName" @click="editingName = false" />
|
||||
</div>
|
||||
<div class="text-caption text-grey-6">{{ selectedUser.email }}</div>
|
||||
<div v-if="selectedUser.last_login" class="text-caption text-grey-5">
|
||||
Derniere connexion: {{ formatDate(selectedUser.last_login) }}
|
||||
</div>
|
||||
</div>
|
||||
<q-btn flat round dense icon="close" @click="selectedUser = null" />
|
||||
<q-btn flat round dense icon="close" @click="selectedUser = null; editingName = false" />
|
||||
</div>
|
||||
|
||||
<!-- Profil employé lié (ERPNext) — lien session ↔ Employee -->
|
||||
<div class="q-mb-md emp-card">
|
||||
<div class="row items-center q-mb-xs">
|
||||
<div class="text-subtitle2"><q-icon name="badge" size="18px" class="q-mr-xs" />Profil employé</div>
|
||||
<q-space />
|
||||
<q-spinner v-if="empResolving" size="16px" color="primary" />
|
||||
</div>
|
||||
<template v-if="!empResolving">
|
||||
<div v-if="selectedEmployee" class="row items-center no-wrap">
|
||||
<div class="col">
|
||||
<div class="text-body2 text-weight-medium">{{ selectedEmployee.employee_name }}</div>
|
||||
<div class="text-caption text-grey-6">
|
||||
{{ [selectedEmployee.designation, selectedEmployee.department].filter(Boolean).join(' · ') || '—' }}
|
||||
<span v-if="selectedEmployee.cell_number"> · {{ selectedEmployee.cell_number }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<q-btn dense flat no-caps color="primary" icon="edit" label="Modifier"
|
||||
@click="openEmpEdit(selectedEmployee.name)" />
|
||||
</div>
|
||||
<div v-else class="row items-center no-wrap">
|
||||
<div class="col text-caption text-grey-6"><q-icon name="link_off" size="14px" /> Aucun profil employé lié à ce compte.</div>
|
||||
<q-btn dense unelevated no-caps color="primary" icon="link" label="Lier un employé"
|
||||
@click="openEmpLink(selectedUser.email)" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Groups assignment -->
|
||||
|
|
@ -255,6 +303,9 @@
|
|||
</q-expansion-item>
|
||||
</div>
|
||||
</q-slide-transition>
|
||||
|
||||
<EmployeeEditDialog v-model="empDlgOpen" :employee-name="empDlgName" :link-email="empDlgLinkEmail"
|
||||
@saved="onEmployeeSaved" />
|
||||
</div>
|
||||
|
||||
<!-- TAB: Groupes -->
|
||||
|
|
@ -381,9 +432,7 @@
|
|||
</div>
|
||||
<div v-else>
|
||||
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs q-px-sm member-row">
|
||||
<q-avatar size="28px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:0.7rem">
|
||||
{{ initial(m) }}
|
||||
</q-avatar>
|
||||
<UserAvatar :email="m.email" :name="m.name || m.username" :size="28" class="q-mr-sm" />
|
||||
<span class="text-body2">{{ m.name || m.username }}</span>
|
||||
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
|
||||
<q-space />
|
||||
|
|
@ -411,9 +460,7 @@
|
|||
<div v-if="memberSearchResults.length" class="member-search-dropdown">
|
||||
<div v-for="u in memberSearchResults" :key="u.pk" class="member-search-item"
|
||||
@click="addUserToCurrentGroup(u)">
|
||||
<q-avatar size="24px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:0.65rem">
|
||||
{{ initial(u) }}
|
||||
</q-avatar>
|
||||
<UserAvatar :email="u.email" :name="u.name || u.username" :size="24" class="q-mr-sm" />
|
||||
<span class="text-body2">{{ u.name || u.username }}</span>
|
||||
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</span>
|
||||
<q-badge v-if="u.groups.includes(selectedGroup)" label="deja membre" color="grey-3" text-color="grey-6" class="q-ml-auto" />
|
||||
|
|
@ -557,7 +604,7 @@
|
|||
</div>
|
||||
<div class="text-caption text-grey-6 q-mb-md">
|
||||
La configuration SMTP (Mailjet) se fait dans ERPNext.
|
||||
<a :href="erpDeskUrl + '/app/email-account'" target="_blank">Configurer</a>
|
||||
<a v-if="can('manage_settings')" :href="erpDeskUrl + '/app/email-account'" target="_blank">Configurer</a>
|
||||
</div>
|
||||
|
||||
<q-separator class="q-my-md" />
|
||||
|
|
@ -710,8 +757,8 @@
|
|||
</q-expansion-item>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 8: Liens rapides -->
|
||||
<div class="ops-card">
|
||||
<!-- SECTION 8: Liens rapides (console admin/infra — ERPNext desk, n8n, Authentik) : admins seulement (manage_settings) -->
|
||||
<div v-if="can('manage_settings')" class="ops-card">
|
||||
<q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7">
|
||||
<template #header>
|
||||
<SectionHeader icon="launch" label="Liens rapides" />
|
||||
|
|
@ -749,6 +796,7 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch, defineAsyncComponent, h, resolveComponent } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { formatDateTimeShort as formatDate } from 'src/composables/useFormatters' // date+heure canonique (source unique)
|
||||
import { Notify } from 'quasar'
|
||||
import { authFetch } from 'src/api/auth'
|
||||
|
|
@ -758,6 +806,10 @@ import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/compos
|
|||
import { usePermissions } from 'src/composables/usePermissions'
|
||||
import { usePermissionMatrix } from 'src/composables/usePermissionMatrix'
|
||||
import { useUserGroups } from 'src/composables/useUserGroups'
|
||||
import { useAvatar } from 'src/composables/useAvatar'
|
||||
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
||||
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue'
|
||||
import { listDocs } from 'src/api/erp'
|
||||
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
|
||||
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
|
||||
import CannedResponsesCard from 'src/components/settings/CannedResponsesCard.vue'
|
||||
|
|
@ -804,15 +856,67 @@ const {
|
|||
|
||||
const {
|
||||
userSearch, userResults, userSearchLoading, userSearchDone,
|
||||
selectedUser, savingGroups,
|
||||
selectedUser, savingGroups, savingName,
|
||||
selectedGroup, groupMembers, groupMembersLoading,
|
||||
addMemberSearch, memberSearchResults, memberSearchLoading,
|
||||
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
|
||||
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, saveUserName,
|
||||
selectGroup, loadGroupMembers, removeFromGroup,
|
||||
debouncedMemberSearch, addUserToCurrentGroup,
|
||||
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite,
|
||||
} = useUserGroups({ permGroups })
|
||||
|
||||
// ── Édition du nom complet (dans le panneau détail utilisateur) ──────────────
|
||||
const editingName = ref(false)
|
||||
const nameDraft = ref('')
|
||||
function startEditName (user) {
|
||||
nameDraft.value = user.name || ''
|
||||
editingName.value = true
|
||||
}
|
||||
async function commitEditName (user) {
|
||||
const ok = await saveUserName(user, nameDraft.value)
|
||||
if (ok) editingName.value = false
|
||||
}
|
||||
|
||||
// ── Profil employé lié au compte sélectionné (session ↔ Employee) ────────────
|
||||
const selectedEmployee = ref(null)
|
||||
const empResolving = ref(false)
|
||||
const empDlgOpen = ref(false)
|
||||
const empDlgName = ref('')
|
||||
const empDlgLinkEmail = ref('')
|
||||
|
||||
async function resolveSelectedEmployee () {
|
||||
selectedEmployee.value = null
|
||||
const email = selectedUser.value?.email
|
||||
if (!email) return
|
||||
empResolving.value = true
|
||||
try {
|
||||
const fields = ['name', 'employee_name', 'designation', 'department', 'cell_number', 'office_extension', 'company_email', 'user_id']
|
||||
let rows = await listDocs('Employee', { filters: { user_id: email }, fields, limit: 1 })
|
||||
if (!rows.length) rows = await listDocs('Employee', { filters: { company_email: email }, fields, limit: 1 })
|
||||
selectedEmployee.value = rows[0] || null
|
||||
} catch { selectedEmployee.value = null } finally { empResolving.value = false }
|
||||
}
|
||||
watch(selectedUser, resolveSelectedEmployee)
|
||||
|
||||
function openEmpEdit (name) { empDlgLinkEmail.value = ''; empDlgName.value = name; empDlgOpen.value = true }
|
||||
function openEmpLink (email) { empDlgName.value = ''; empDlgLinkEmail.value = email; empDlgOpen.value = true }
|
||||
function onEmployeeSaved () { resolveSelectedEmployee() }
|
||||
|
||||
// ── Ouverture directe d'un utilisateur via ?user=<email> (bouton « Éditer »
|
||||
// depuis la page Équipe). On force l'onglet Utilisateurs, on recherche par
|
||||
// courriel puis on sélectionne la correspondance exacte.
|
||||
const route = useRoute()
|
||||
async function openUserByEmail (email) {
|
||||
const target = String(email || '').trim().toLowerCase()
|
||||
if (!target) return
|
||||
permTab.value = 'users'
|
||||
userSearch.value = target
|
||||
await searchUsers()
|
||||
const match = userResults.value.find(u => (u.email || '').toLowerCase() === target)
|
||||
if (match) selectUser(match)
|
||||
else Notify.create({ type: 'warning', message: `Aucun compte trouvé pour ${target}`, timeout: 3000 })
|
||||
}
|
||||
|
||||
async function copyTempPassword () {
|
||||
if (!lastInviteResult.value?.temp_password) return
|
||||
try {
|
||||
|
|
@ -854,8 +958,24 @@ function notify (msg, type = 'positive', timeout = 1500) {
|
|||
Notify.create({ type, message: msg, timeout })
|
||||
}
|
||||
|
||||
function initial (u) {
|
||||
return (u.name || u.username || '?')[0].toUpperCase()
|
||||
// Avatar d'équipe : un admin peut définir la photo d'un membre (upload via ?as=).
|
||||
const { uploadAvatar, fileToAvatarDataUrl } = useAvatar()
|
||||
const teamAvatarInput = ref(null)
|
||||
let teamAvatarTarget = null
|
||||
function pickTeamAvatar (user) {
|
||||
teamAvatarTarget = user
|
||||
teamAvatarInput.value?.click()
|
||||
}
|
||||
async function onTeamAvatarFile (e) {
|
||||
const file = e.target.files && e.target.files[0]
|
||||
e.target.value = ''
|
||||
if (!file || !teamAvatarTarget) return
|
||||
try {
|
||||
const dataUrl = await fileToAvatarDataUrl(file)
|
||||
await uploadAvatar(dataUrl, { as: teamAvatarTarget.email })
|
||||
} catch (err) {
|
||||
Notify.create({ type: 'negative', message: 'Image illisible: ' + err.message })
|
||||
}
|
||||
}
|
||||
|
||||
// formatDate → useFormatters.formatDateTimeShort (date+heure canonique)
|
||||
|
|
@ -905,14 +1025,16 @@ onMounted(async () => {
|
|||
}
|
||||
if (isLoaded.value && can('manage_permissions')) {
|
||||
loadPerms()
|
||||
searchUsers()
|
||||
if (route.query.user) openUserByEmail(String(route.query.user))
|
||||
else searchUsers()
|
||||
}
|
||||
})
|
||||
|
||||
watch(isLoaded, (loaded) => {
|
||||
if (loaded && can('manage_permissions') && !permGroups.value.length) {
|
||||
loadPerms()
|
||||
searchUsers()
|
||||
if (route.query.user) openUserByEmail(String(route.query.user))
|
||||
else searchUsers()
|
||||
}
|
||||
}, { immediate: false })
|
||||
|
||||
|
|
@ -973,6 +1095,11 @@ async function testSms () {
|
|||
.user-card--selected { background: #eef2ff; border-left: 3px solid #6366f1; }
|
||||
|
||||
.user-detail-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
|
||||
.ua-edit { position: relative; display: inline-flex; }
|
||||
.ua-edit--can { cursor: pointer; }
|
||||
.ua-edit__cam { position: absolute; right: -2px; bottom: -2px; width: 18px; height: 18px; border-radius: 50%;
|
||||
background: var(--ops-primary, #16a34a); display: flex; align-items: center; justify-content: center;
|
||||
border: 2px solid #fff; }
|
||||
|
||||
.group-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||||
.group-card { padding: 14px; border: 1px solid #e2e8f0; border-radius: 10px; cursor: pointer; transition: all 0.15s; }
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@
|
|||
<div class="row items-center q-mb-sm">
|
||||
<div class="text-subtitle1 text-weight-bold">{{ selected.vendor || '(fournisseur à confirmer)' }}</div>
|
||||
<q-space />
|
||||
<q-btn flat round dense icon="open_in_new" :href="erpBase + '/app/supplier-invoice-intake/' + encodeURIComponent(selected.name)" target="_blank"><q-tooltip>Ouvrir l'intake dans ERPNext</q-tooltip></q-btn>
|
||||
<q-btn flat round dense icon="close" @click="selected = null" />
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@
|
|||
</template>
|
||||
<template #title-suffix>
|
||||
<span> · <span class="text-primary">#{{ modalTicket?.legacy_ticket_id || modalTicket?.name }}</span></span>
|
||||
<template v-if="modalTicket?.customer_name"> · {{ modalTicket.customer_name }}</template>
|
||||
<template v-if="modalTicket?.customer_name"> · <router-link v-if="modalTicket?.customer" :to="'/clients/' + encodeURIComponent(modalTicket.customer)" class="erp-link">{{ modalTicket.customer_name }}</router-link><template v-else>{{ modalTicket.customer_name }}</template></template>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<q-btn flat dense no-caps :icon="myFollows.includes(modalTicket?.name) ? 'star' : 'star_border'"
|
||||
|
|
|
|||
|
|
@ -12,5 +12,16 @@
|
|||
{ "type": "function", "function": { "name": "get_open_tickets", "description": "Get open support tickets: subject, status, priority, date", "parameters": { "type": "object", "properties": { "customer_id": { "type": "string" } }, "required": ["customer_id"] } } },
|
||||
{ "type": "function", "function": { "name": "create_ticket", "description": "Create a support ticket when customer reports a problem needing agent follow-up", "parameters": { "type": "object", "properties": { "customer_id": { "type": "string" }, "subject": { "type": "string" }, "description": { "type": "string" }, "priority": { "type": "string", "enum": ["Low", "Medium", "High", "Urgent"] } }, "required": ["customer_id", "subject"] } } },
|
||||
{ "type": "function", "function": { "name": "get_chat_link", "description": "Get the web chat link for this conversation so the customer can continue chatting in a browser instead of SMS", "parameters": { "type": "object", "properties": {} } } },
|
||||
{ "type": "function", "function": { "name": "create_dispatch_job", "description": "Create an emergency dispatch job and auto-assign to the nearest available technician. Use when: device offline with fiber issue (Rx power < -25 dBm, Branch Fiber Cut), customer reports no internet and troubleshooting fails, or any situation requiring on-site technician.", "parameters": { "type": "object", "properties": { "customer_id": { "type": "string", "description": "Customer ID (e.g. C-LPB4)" }, "service_location": { "type": "string", "description": "Service Location name (from get_service_locations)" }, "subject": { "type": "string", "description": "Job description (e.g. 'Branch Fiber Cut - 691 rue des Hirondelles')" }, "priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Job priority (default: high for emergencies)" }, "job_type": { "type": "string", "enum": ["Installation", "Réparation", "Maintenance", "Retrait", "Dépannage", "Autre"], "description": "Type of work (default: Dépannage)" }, "notes": { "type": "string", "description": "Additional context (signal levels, error details, customer complaint)" } }, "required": ["customer_id", "subject"] } } }
|
||||
{ "type": "function", "function": { "name": "create_dispatch_job", "description": "Create an emergency dispatch job and auto-assign to the nearest available technician. Use when: device offline with fiber issue (Rx power < -25 dBm, Branch Fiber Cut), customer reports no internet and troubleshooting fails, or any situation requiring on-site technician.", "parameters": { "type": "object", "properties": { "customer_id": { "type": "string", "description": "Customer ID (e.g. C-LPB4)" }, "service_location": { "type": "string", "description": "Service Location name (from get_service_locations)" }, "subject": { "type": "string", "description": "Job description (e.g. 'Branch Fiber Cut - 691 rue des Hirondelles')" }, "priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Job priority (default: high for emergencies)" }, "job_type": { "type": "string", "enum": ["Installation", "Réparation", "Maintenance", "Retrait", "Dépannage", "Autre"], "description": "Type of work (default: Dépannage)" }, "notes": { "type": "string", "description": "Additional context (signal levels, error details, customer complaint)" } }, "required": ["customer_id", "subject"] } } },
|
||||
|
||||
{ "audience": "staff", "mode": "read", "title": "Lister les techniciens", "type": "function", "function": { "name": "list_technicians", "description": "STAFF/dispatch. Liste les techniciens (id, nom, statut, compétences). Sert à RÉSOUDRE un technicien par son nom (« Simon » → TECH-4693) avant d'assigner/planifier. Filtre optionnel par nom partiel et/ou compétence.", "parameters": { "type": "object", "properties": { "query": { "type": "string", "description": "Nom partiel du technicien (optionnel)" }, "skill": { "type": "string", "description": "Compétence requise (optionnel), ex. fibre, monteur, sans-fil" } }, "required": [] } } },
|
||||
{ "audience": "staff", "mode": "read", "title": "Détails du job", "type": "function", "function": { "name": "get_job", "description": "STAFF/dispatch. Détails d'un Dispatch Job (sujet, statut, technicien assigné, date/heure, client, lieu, durée). Sert à vérifier un job avant de le modifier/réassigner/reporter.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job, ex. DJ-2026-0001 ou LEG-253958" } }, "required": ["job"] } } },
|
||||
{ "audience": "staff", "mode": "read", "title": "Trouver un créneau", "type": "function", "function": { "name": "find_slot", "description": "STAFF/dispatch. Propose les meilleurs créneaux disponibles (technicien + date + heure) selon la durée et la compétence requise. LECTURE — ne réserve rien ; présente les options à l'utilisateur.", "parameters": { "type": "object", "properties": { "duration_h": { "type": "number", "description": "Durée en heures (défaut 1)" }, "skill": { "type": "string", "description": "Compétence requise (optionnel)" }, "after_date": { "type": "string", "description": "Chercher à partir de cette date AAAA-MM-JJ (défaut aujourd'hui)" } }, "required": [] } } },
|
||||
{ "audience": "staff", "mode": "write", "permission": "create_jobs", "title": "Créer un job", "type": "function", "function": { "name": "create_job", "description": "STAFF/dispatch. Crée un Dispatch Job (intervention). ÉCRITURE — proposée puis confirmée par l'utilisateur avant exécution. auto_assign=true assigne au meilleur tech disponible.", "parameters": { "type": "object", "properties": { "subject": { "type": "string", "description": "Description du travail" }, "customer_id": { "type": "string", "description": "ID client (optionnel)" }, "service_location": { "type": "string", "description": "Nom du lieu de service (optionnel)" }, "priority": { "type": "string", "enum": ["low", "medium", "high"], "description": "Priorité (défaut high pour urgence, medium sinon)" }, "job_type": { "type": "string", "enum": ["Installation", "Réparation", "Maintenance", "Retrait", "Dépannage", "Autre"], "description": "Type de travail (défaut Dépannage)" }, "notes": { "type": "string", "description": "Contexte additionnel" }, "auto_assign": { "type": "boolean", "description": "Assigner automatiquement au meilleur tech dispo (défaut true)" } }, "required": ["subject"] } } },
|
||||
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Assigner un technicien", "type": "function", "function": { "name": "assign_tech", "description": "STAFF/dispatch. Assigne OU réassigne un Dispatch Job à un technicien (pose aussi l'heure au premier trou libre du quart). ÉCRITURE — confirmée avant exécution. Résous d'abord le tech via list_technicians pour obtenir tech_id.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien (TECH-xxxx), via list_technicians" }, "date": { "type": "string", "description": "Date AAAA-MM-JJ (optionnel)" }, "start": { "type": "string", "description": "Heure HH:MM (optionnel)" } }, "required": ["job", "tech_id"] } } },
|
||||
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Ajouter un assistant", "type": "function", "function": { "name": "add_assistant", "description": "STAFF/dispatch. Ajoute un technicien en renfort (assistant) sur un job — le tech assigné (lead) reste inchangé. ÉCRITURE — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "tech_id": { "type": "string", "description": "id du technicien assistant (TECH-xxxx)" }, "tech_name": { "type": "string", "description": "Nom du technicien (optionnel, pour l'affichage)" } }, "required": ["job", "tech_id"] } } },
|
||||
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Changer le statut", "type": "function", "function": { "name": "set_job_status", "description": "STAFF/dispatch. Change le statut d'un Dispatch Job. ÉCRITURE — confirmée avant exécution. Cancelled = annuler l'intervention (conséquent).", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "status": { "type": "string", "enum": ["open", "On Hold", "Cancelled", "assigned"], "description": "Nouveau statut" } }, "required": ["job", "status"] } } },
|
||||
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Reporter le rendez-vous", "type": "function", "function": { "name": "reschedule_job", "description": "STAFF/dispatch. Reporte un Dispatch Job à une nouvelle date (et heure). Conserve le technicien assigné si possible. ÉCRITURE — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "job": { "type": "string", "description": "Identifiant du Dispatch Job" }, "date": { "type": "string", "description": "Nouvelle date AAAA-MM-JJ" }, "start": { "type": "string", "description": "Nouvelle heure HH:MM (optionnel)" } }, "required": ["job", "date"] } } },
|
||||
{ "audience": "staff", "mode": "write", "permission": "assign_jobs", "title": "Créer un horaire récurrent", "type": "function", "function": { "name": "create_recurring_shift", "description": "STAFF/planification. Définit le QUART RÉCURRENT (horaire hebdomadaire) d'un technicien — matérialisé automatiquement en assignations sur l'horizon. ÉCRITURE — confirmée avant exécution. days = jours travaillés parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions demandées (ex. « sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00.", "parameters": { "type": "object", "properties": { "technicien_id": { "type": "string", "description": "id du technicien (TECH-xxxx), via list_technicians" }, "days": { "type": "array", "items": { "type": "string" }, "description": "Jours travaillés: mon,tue,wed,thu,fri,sat,sun" }, "start": { "type": "string", "description": "Heure de début HH:MM, ex. 08:00" }, "end": { "type": "string", "description": "Heure de fin HH:MM, ex. 16:00" } }, "required": ["technicien_id", "days", "start", "end"] } } },
|
||||
{ "audience": "staff", "mode": "write", "title": "Suivre", "type": "function", "function": { "name": "follow_doc", "description": "STAFF. (Dé)suivre un job ou un ticket pour recevoir les notifications de mise à jour (l'utilisateur connecté devient abonné). ÉCRITURE légère — confirmée avant exécution.", "parameters": { "type": "object", "properties": { "doctype": { "type": "string", "enum": ["Dispatch Job", "Issue"], "description": "Type de document à suivre" }, "name": { "type": "string", "description": "Identifiant du job ou du ticket" }, "follow": { "type": "boolean", "description": "true = suivre (défaut), false = ne plus suivre" } }, "required": ["doctype", "name"] } } }
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
'use strict'
|
||||
const cfg = require('./config')
|
||||
const { log, erpFetch } = require('./helpers')
|
||||
const TOOLS = require('./agent-tools.json')
|
||||
const ALL_TOOLS = require('./agent-tools.json')
|
||||
|
||||
// agent-tools.json est le registre UNIQUE partagé (parité UI↔NL). Chaque outil porte des
|
||||
// métadonnées HORS du sous-objet `function` : audience ('customer'|'staff'), mode ('read'|'write'),
|
||||
// permission, title. On les RETIRE avant l'appel modèle (l'endpoint OpenAI-compat de Gemini ne
|
||||
// reçoit que {type, function}) et on FILTRE par audience :
|
||||
// - le répondeur SMS/voix client ne voit QUE les outils 'customer' (sécurité : un client ne doit
|
||||
// jamais pouvoir déclencher un outil d'écriture STAFF comme create_recurring_shift) ;
|
||||
// - le copilote STAFF (lib/staff-agent.js) charge les outils 'staff'.
|
||||
const toolsForModel = (list) => list.map(t => ({ type: t.type, function: t.function }))
|
||||
const toolsByAudience = (audience) => ALL_TOOLS.filter(t => (t.audience || 'customer') === audience)
|
||||
const TOOLS = toolsForModel(toolsByAudience('customer'))
|
||||
|
||||
let _convContext = {}
|
||||
|
||||
|
|
@ -509,4 +520,4 @@ const handleAgentApi = async (req, res, method, urlPath) => {
|
|||
j(res, 404, { error: 'Agent endpoint not found' })
|
||||
}
|
||||
|
||||
module.exports = { runAgent, TOOLS, execTool, handleAgentApi, getFlows }
|
||||
module.exports = { runAgent, TOOLS, execTool, handleAgentApi, getFlows, toolsForModel, toolsByAudience }
|
||||
|
|
|
|||
|
|
@ -59,6 +59,24 @@ async function findUserByEmail (email) {
|
|||
return user ? { user } : { error: 'User not found: ' + email, status: 404 }
|
||||
}
|
||||
|
||||
// Cache courriel → NOM COMPLET Authentik (TTL 5 min). Sert au « From » sortant
|
||||
// personnel : on veut afficher le nom canonique (« Louis-Paul Bourdon ») et non
|
||||
// un nom dérivé du préfixe courriel (« louis@ » → « Louis »). '' si introuvable.
|
||||
const nameCache = new Map() // email → { name, ts }
|
||||
async function getDisplayNameByEmail (email) {
|
||||
const key = String(email || '').trim().toLowerCase()
|
||||
if (!key) return ''
|
||||
const hit = nameCache.get(key)
|
||||
if (hit && Date.now() - hit.ts < 300000) return hit.name
|
||||
let name = ''
|
||||
try {
|
||||
const lookup = await findUserByEmail(key)
|
||||
if (!lookup.error && lookup.user && lookup.user.name) name = String(lookup.user.name).trim()
|
||||
} catch { /* Authentik indispo → repli sur dérivation locale côté appelant */ }
|
||||
nameCache.set(key, { name, ts: Date.now() })
|
||||
return name
|
||||
}
|
||||
|
||||
// Repli : quand le courriel ne correspond à aucun compte OPS (courriel vide côté whoami, ou différent de
|
||||
// celui provisionné), on retrouve l'utilisateur par son username Authentik (transmis aussi par whoami).
|
||||
async function findUserByUsername (username) {
|
||||
|
|
@ -68,6 +86,20 @@ async function findUserByUsername (username) {
|
|||
return user ? { user } : { error: 'User not found: ' + username, status: 404 }
|
||||
}
|
||||
|
||||
// Résout le profil EMPLOYÉ ERPNext lié à un compte de session (courriel Authentik).
|
||||
// Lien = Employee.user_id (posé à l'invitation) ; REPLI sur company_email. null si non lié.
|
||||
const EMPLOYEE_FIELDS = ['name', 'employee_name', 'designation', 'department', 'cell_number', 'office_extension', 'company_email', 'user_id', 'status']
|
||||
async function resolveEmployeeForEmail (email) {
|
||||
const e = String(email || '').trim()
|
||||
if (!e) return null
|
||||
try {
|
||||
const erp = require('./erp')
|
||||
let rows = await erp.list('Employee', { filters: [['user_id', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
|
||||
if (!rows.length) rows = await erp.list('Employee', { filters: [['company_email', '=', e]], fields: EMPLOYEE_FIELDS, limit: 1 })
|
||||
return rows[0] || null
|
||||
} catch (err) { log('resolveEmployeeForEmail ' + e + ': ' + err.message); return null }
|
||||
}
|
||||
|
||||
function validatePermissions (body) {
|
||||
const validKeys = new Set(CAPABILITIES.map(c => c.key))
|
||||
const result = {}
|
||||
|
|
@ -117,10 +149,14 @@ async function handle (req, res, method, path, url) {
|
|||
if (typeof val === 'boolean') merged[key] = val
|
||||
}
|
||||
|
||||
// Profil employé lié (log de la session ↔ Employee) — non bloquant si ERPNext indispo.
|
||||
const employee = await resolveEmployeeForEmail(user.email)
|
||||
log(`Auth: session ${user.email} (${user.name}) → employé ${employee ? employee.name : 'non lié'}`)
|
||||
|
||||
return json(res, 200, {
|
||||
email: user.email, username: user.username, name: user.name,
|
||||
groups: userGroups.map(g => g.name), is_superuser: user.is_superuser || false,
|
||||
capabilities: merged, overrides,
|
||||
capabilities: merged, overrides, employee,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +234,27 @@ async function handle (req, res, method, path, url) {
|
|||
return json(res, 200, { ok: true, email, overrides })
|
||||
}
|
||||
|
||||
// PUT /auth/users/{email}/name — modifier le nom complet Authentik.
|
||||
// Source unique du nom affiché partout (Users & Permissions, From sortant
|
||||
// personnel). L'adresse et les groupes ne changent pas.
|
||||
if (resource === 'users' && parts[2] === 'name' && method === 'PUT') {
|
||||
const email = decodeURIComponent(parts[1])
|
||||
const lookup = await findUserByEmail(email)
|
||||
if (lookup.error) return json(res, lookup.status, { error: lookup.error })
|
||||
const { user } = lookup
|
||||
|
||||
const body = await parseBody(req)
|
||||
const name = String(body.name || '').trim().replace(/[\r\n]/g, '').slice(0, 120)
|
||||
if (!name) return json(res, 400, { error: 'name required' })
|
||||
|
||||
const r = await akFetch('/core/users/' + user.pk + '/', 'PATCH', { name })
|
||||
if (r.status !== 200) return json(res, 502, { error: 'Authentik update failed: ' + r.status })
|
||||
|
||||
nameCache.delete(email.toLowerCase())
|
||||
log(`Auth: updated name for ${email}: ${name}`)
|
||||
return json(res, 200, { ok: true, email, name })
|
||||
}
|
||||
|
||||
if (resource === 'users' && parts[2] === 'groups' && method === 'PUT') {
|
||||
const email = decodeURIComponent(parts[1])
|
||||
const lookup = await findUserByEmail(email)
|
||||
|
|
@ -482,4 +539,30 @@ async function handle (req, res, method, path, url) {
|
|||
}
|
||||
}
|
||||
|
||||
module.exports = { handle, CAPABILITIES, OPS_GROUPS }
|
||||
// Capacités EFFECTIVES d'un utilisateur (ops_permissions des groupes Authentik ∪ overrides perso).
|
||||
// RÉUTILISE les mêmes primitives que GET /auth/permissions (findUserBy*, fetchGroups, CAPABILITIES,
|
||||
// OPS_GROUPS) sans passer par HTTP. Sert au copilote STAFF (lib/staff-agent.js) pour gater les outils
|
||||
// d'ÉCRITURE par capacité (assign_jobs, create_jobs, …) — parité avec le usePermissions du front.
|
||||
// → { configured, found, is_superuser, capabilities:{cap:bool}, groups:[...] }
|
||||
// configured=false quand Authentik n'est pas branché (dev/aperçu) → l'appelant décide (ne pas bloquer en dev).
|
||||
async function effectiveCapabilities (email, username) {
|
||||
if (!cfg.AUTHENTIK_TOKEN) return { configured: false, found: false, is_superuser: false, capabilities: {}, groups: [] }
|
||||
let lookup = email ? await findUserByEmail(email) : { error: 'no email', status: 404 }
|
||||
if (lookup.error && username) lookup = await findUserByUsername(username)
|
||||
if (lookup.error) return { configured: true, found: false, is_superuser: false, capabilities: {}, groups: [] }
|
||||
const { user } = lookup
|
||||
const allGroups = await fetchGroups()
|
||||
const userGroupPks = new Set(user.groups || [])
|
||||
const userGroups = allGroups.filter(g => userGroupPks.has(g.pk) && OPS_GROUPS.includes(g.name))
|
||||
const merged = {}
|
||||
for (const cap of CAPABILITIES) merged[cap.key] = false
|
||||
for (const g of userGroups) {
|
||||
const perms = g.attributes?.ops_permissions || {}
|
||||
for (const [k, v] of Object.entries(perms)) if (v === true) merged[k] = true
|
||||
}
|
||||
const overrides = user.attributes?.ops_permissions_override || {}
|
||||
for (const [k, v] of Object.entries(overrides)) if (typeof v === 'boolean') merged[k] = v
|
||||
return { configured: true, found: true, is_superuser: user.is_superuser || false, capabilities: merged, groups: userGroups.map(g => g.name) }
|
||||
}
|
||||
|
||||
module.exports = { handle, CAPABILITIES, OPS_GROUPS, getDisplayNameByEmail, effectiveCapabilities }
|
||||
|
|
|
|||
110
services/targo-hub/lib/avatars.js
Normal file
110
services/targo-hub/lib/avatars.js
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
'use strict'
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Photos de profil (avatars) par utilisateur. Stockage serveur keyé par courriel
|
||||
// Authentik → suit l'usager entre appareils. Même patron d'identité que
|
||||
// user-prefs.js : l'identité = x-authentik-email (forward-auth SSO). Un usager ne
|
||||
// peut modifier QUE son propre avatar ; un admin peut cibler un autre via ?as=.
|
||||
//
|
||||
// GET /avatars/{email} → l'image (ou 404). Public-ish (chargée par <img>).
|
||||
// POST /avatars { dataUrl } → définit MON avatar (self)
|
||||
// POST /avatars?as=<e> { dataUrl } → (ADMIN) définit l'avatar d'un autre
|
||||
// DELETE /avatars → supprime MON avatar (self) · ?as= pour admin
|
||||
//
|
||||
// Repli visuel (initiales + couleur) = côté client (<UserAvatar>) sur 404/erreur.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const { json, parseBody, readJsonFile, writeJsonFile } = require('./helpers')
|
||||
|
||||
const DIR = '/app/data/avatars'
|
||||
const INDEX = path.join(DIR, '_index.json') // { emailLower: { ext, mime, ts } }
|
||||
function ensureDir () { if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true }) }
|
||||
|
||||
// SVG volontairement EXCLU (une image de profil SVG = vecteur XSS via <img>/inline).
|
||||
const ALLOWED = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
|
||||
const MAX_BYTES = 1024 * 1024 // 1 Mo — les avatars sont petits ; le client redimensionne avant l'envoi.
|
||||
|
||||
const meOf = (req) => String(req.headers['x-authentik-email'] || '').toLowerCase()
|
||||
function isAdmin (req) {
|
||||
const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '')
|
||||
return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g)
|
||||
}
|
||||
|
||||
function decodeDataUrl (dataUrl) {
|
||||
if (typeof dataUrl !== 'string') return null
|
||||
const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/)
|
||||
if (!m) return null
|
||||
const mime = m[1].toLowerCase()
|
||||
if (!ALLOWED[mime]) return { error: 'type' }
|
||||
if (!m[2]) return { error: 'type' } // on exige du base64
|
||||
let buffer
|
||||
try { buffer = Buffer.from(m[3], 'base64') } catch { return null }
|
||||
if (!buffer.length) return null
|
||||
if (buffer.length > MAX_BYTES) return { error: 'too_large', size: buffer.length }
|
||||
return { mime, ext: ALLOWED[mime], buffer }
|
||||
}
|
||||
|
||||
const fileFor = (email, ext) => path.join(DIR, crypto.createHash('sha1').update(email).digest('hex') + '.' + ext)
|
||||
|
||||
async function handle (req, res, method, path_) {
|
||||
// GET /avatars → index { avatars: { emailLower: version } } : liste des courriels
|
||||
// AYANT une photo (+ version pour cache-bust). Le client s'en sert pour n'afficher
|
||||
// le <img> QUE pour ces courriels et éviter un 404 par avatar sans photo.
|
||||
if (method === 'GET' && (path_ === '/avatars' || path_ === '/avatars/')) {
|
||||
const idx = readJsonFile(INDEX, {})
|
||||
const avatars = {}
|
||||
for (const [email, meta] of Object.entries(idx)) avatars[email] = (meta && meta.ts) || 1
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
return json(res, 200, { avatars })
|
||||
}
|
||||
|
||||
// GET /avatars/{email}
|
||||
if (method === 'GET') {
|
||||
const email = decodeURIComponent(path_.replace(/^\/avatars\/?/, '')).trim().toLowerCase()
|
||||
if (!email) return json(res, 400, { error: 'email requis' })
|
||||
const idx = readJsonFile(INDEX, {})
|
||||
const meta = idx[email]
|
||||
if (!meta) { res.statusCode = 404; return res.end() }
|
||||
const p = fileFor(email, meta.ext)
|
||||
if (!fs.existsSync(p)) { res.statusCode = 404; return res.end() }
|
||||
res.setHeader('Content-Type', meta.mime || 'application/octet-stream')
|
||||
// Change de contenu à la même URL → revalidation courte + ?v=<ts> côté client pour le cache-busting immédiat.
|
||||
res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate')
|
||||
res.statusCode = 200
|
||||
return res.end(fs.readFileSync(p))
|
||||
}
|
||||
|
||||
const me = meOf(req)
|
||||
const as = String(new URL(req.url, 'http://localhost').searchParams.get('as') || '').toLowerCase()
|
||||
const target = (as && as !== me && isAdmin(req)) ? as : me
|
||||
if (!target) return json(res, 400, { error: 'utilisateur inconnu (x-authentik-email absent)' })
|
||||
if (as && as !== me && !isAdmin(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
|
||||
|
||||
if (method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const decoded = decodeDataUrl(b && b.dataUrl)
|
||||
if (!decoded) return json(res, 400, { error: 'dataUrl image invalide' })
|
||||
if (decoded.error === 'type') return json(res, 415, { error: 'format non supporté (png, jpeg, webp, gif)' })
|
||||
if (decoded.error === 'too_large') return json(res, 413, { error: `image trop lourde (${Math.round(decoded.size / 1024)} Ko > 1 Mo)` })
|
||||
ensureDir()
|
||||
const idx = readJsonFile(INDEX, {})
|
||||
// Retire un ancien fichier d'extension différente pour éviter les orphelins.
|
||||
if (idx[target] && idx[target].ext !== decoded.ext) { try { fs.unlinkSync(fileFor(target, idx[target].ext)) } catch { /* absent */ } }
|
||||
fs.writeFileSync(fileFor(target, decoded.ext), decoded.buffer)
|
||||
const ts = Date.now()
|
||||
idx[target] = { ext: decoded.ext, mime: decoded.mime, ts }
|
||||
writeJsonFile(INDEX, idx)
|
||||
return json(res, 200, { ok: true, email: target, version: ts })
|
||||
}
|
||||
|
||||
if (method === 'DELETE') {
|
||||
const idx = readJsonFile(INDEX, {})
|
||||
if (idx[target]) { try { fs.unlinkSync(fileFor(target, idx[target].ext)) } catch { /* absent */ } ; delete idx[target]; writeJsonFile(INDEX, idx) }
|
||||
return json(res, 200, { ok: true, email: target })
|
||||
}
|
||||
|
||||
return json(res, 405, { error: 'method not allowed' })
|
||||
}
|
||||
|
||||
module.exports = { handle }
|
||||
|
|
@ -2798,4 +2798,7 @@ module.exports = {
|
|||
parseCsv, parseMapCsv, parseGiftbitCsv,
|
||||
matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
|
||||
renderTemplate, renderNamedTemplate,
|
||||
// Enregistrement de campagne réutilisable (lib/events.js crée une campagne pour le blast d'invitations :
|
||||
// suivi Mailjet via X-MJ-CustomID `<id>:<index>` → webhook existant, report.csv, /campaigns list/détail, SSE).
|
||||
newCampaignId, loadCampaign, saveCampaign,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,11 +195,16 @@ function getConversation (token) {
|
|||
return conv
|
||||
}
|
||||
|
||||
function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', fromName = '', fromEmail = '', toEmail = '', toRaw = '', ccRaw = '', emailDate = '', gmail_id = '', sendAs = '' }) {
|
||||
function addMessage (conv, { from, text, type = 'text', via = 'web', media = '', html = '', agent = '', agentName = '', fromName = '', fromEmail = '', toEmail = '', toRaw = '', ccRaw = '', emailDate = '', gmail_id = '', sendAs = '' }) {
|
||||
const msg = { id: crypto.randomUUID(), from, text, type, via, media, ts: new Date().toISOString() }
|
||||
if (html) msg.html = html // courriel : HTML complet (rendu WYSIWYG iframe côté Ops)
|
||||
if (from === 'agent' && agent) msg.agent = agent // identité de l'agent qui répond → stats « réponses par employé » (classement Inbox)
|
||||
if (fromName) msg.fromName = fromName // expéditeur RÉEL (en-tête From) PAR message → fil multi-expéditeurs affiche le bon nom
|
||||
if (from === 'agent' && agentName) msg.agentName = agentName // NOM COMPLET de l'agent (sender_full_name + affichage fil), résolu du courriel via Authentik
|
||||
// fromName = expéditeur RÉEL par message. Priorité : en-tête From (courriel ingéré) ;
|
||||
// sinon, pour un message d'agent composé chez nous, le NOM COMPLET de l'agent → le fil
|
||||
// affiche « Louis-Paul Bourdon » (le collègue) et non le login dérivé « Louis ».
|
||||
if (fromName) msg.fromName = fromName
|
||||
else if (from === 'agent' && agentName) msg.fromName = agentName
|
||||
if (fromEmail) msg.fromEmail = fromEmail
|
||||
if (toEmail) msg.toEmail = toEmail // adresse de DESTINATION principale (mailbox reçue, ex. support@targo.ca) → affichée « à <…> »
|
||||
if (toRaw) msg.toRaw = toRaw // en-tête To COMPLET (tous les destinataires) → affichage « à moi, Dominique » au dépli (style Gmail)
|
||||
|
|
@ -284,7 +289,7 @@ async function notifyCustomer (conv, message) {
|
|||
const base = conv.lastSubject || conv.subject || ''
|
||||
const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base))
|
||||
const outHtml = await require('./rating').expandRatingMarker(message.html || '', conv)
|
||||
const r = await gmail.sendMessage({ to: message.replyToOverride || conv.email, cc: message.cc || '', subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(message.sendAs, message.agent) })
|
||||
const r = await gmail.sendMessage({ to: message.replyToOverride || conv.email, cc: message.cc || '', subject: subj, body: message.text || '', html: outHtml, attachments: resolveAttachments(message.attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: await resolveSendFrom(message.sendAs, message.agent) })
|
||||
if (r && r.id) { conv.lastGmailId = r.id; conv._gmailIds = conv._gmailIds || []; if (!conv._gmailIds.includes(r.id)) conv._gmailIds.push(r.id) } // mémorise l'ID Gmail du sortant → la re-synchro du fil ne le ré-ajoute pas en double
|
||||
if (r && r.threadId && !conv.threadId) conv.threadId = r.threadId // 1er envoi → mémorise le fil Gmail pour re-threader la réponse (en + du tag [Ticket #…])
|
||||
log(`Email reply sent for conv ${conv.token} → ${conv.email} (gmail ${r.id})`)
|
||||
|
|
@ -456,8 +461,13 @@ function agentDisplayName (email) {
|
|||
return local.split(/[._-]+/).filter(Boolean).map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' ')
|
||||
}
|
||||
// From sortant PERSONNALISÉ : « Gilles Drolet » <support@targo.ca>. L'ADRESSE reste l'alias surveillé (les réponses reviennent dans l'inbox partagé) ; seul le nom affiché change. Sans nom/agent → undefined ⇒ défaut Gmail (« Service TARGO »).
|
||||
function agentSendFrom (email) {
|
||||
const name = agentDisplayName(email)
|
||||
async function agentSendFrom (email) {
|
||||
// NOM CANONIQUE d'abord (Authentik « name » : « Louis-Paul Bourdon »), puis
|
||||
// REPLI sur la dérivation du préfixe courriel (« louis@ » → « Louis »). Corrige
|
||||
// les courriels courts (louis@) qui n'exposaient que le prénom.
|
||||
let name = ''
|
||||
try { name = await require('./auth').getDisplayNameByEmail(email) } catch { /* repli ci-dessous */ }
|
||||
if (!name) name = agentDisplayName(email)
|
||||
if (!name) return undefined
|
||||
const base = require('./gmail').sendFrom()
|
||||
const addr = (String(base).match(/<([^>]+)>/) || [null, base])[1].trim()
|
||||
|
|
@ -478,9 +488,9 @@ function identityToFrom (i) { if (!i || !i.address) return ''; const n = String(
|
|||
const SEND_ALIASES = SENDER_IDENTITIES.map(i => String(i.address).toLowerCase())
|
||||
const DEFAULT_SENDER = process.env.GMAIL_DEFAULT_SENDER || identityToFrom(SENDER_IDENTITIES[0]) || require('./gmail').sendFrom()
|
||||
// From sortant : 'personal' = nom de l'agent ; "Nom <alias vérifié>" = tel quel ; sinon défaut groupe.
|
||||
function resolveSendFrom (from, agentEmail) {
|
||||
async function resolveSendFrom (from, agentEmail) {
|
||||
const v = String(from || '').trim()
|
||||
if (v === 'personal') return agentSendFrom(agentEmail) || DEFAULT_SENDER
|
||||
if (v === 'personal') return (await agentSendFrom(agentEmail)) || DEFAULT_SENDER
|
||||
if (v) { const addr = (v.match(/<([^>]+)>/) || [null, v])[1].trim().toLowerCase(); if (SEND_ALIASES.includes(addr)) return v.replace(/[\r\n]/g, '') }
|
||||
return DEFAULT_SENDER
|
||||
}
|
||||
|
|
@ -1102,11 +1112,12 @@ async function sendNewEmail ({ to, cc, bcc, subject, body, html, attachments, cu
|
|||
if (!conv) { conv = createConversation({ phone: null, customer: customer || null, customerName: customerName || dest, subject: subject || 'Courriel' }); conv.channel = 'email'; conv.email = dest }
|
||||
// réponse dans le fil si la conversation existe déjà (threadId connu)
|
||||
const outHtml = await require('./rating').expandRatingMarker(html || '', conv)
|
||||
const r = await gmail.sendMessage({ to: dest, cc: cc || '', bcc: bcc || '', subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: outHtml, attachments: resolveAttachments(attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: resolveSendFrom(sendAs, agentEmail) })
|
||||
const r = await gmail.sendMessage({ to: dest, cc: cc || '', bcc: bcc || '', subject: withTicketTag(conv, subject || conv.lastSubject || '(sans objet)'), body: body || '', html: outHtml, attachments: resolveAttachments(attachments), threadId: conv.threadId, inReplyTo: conv.lastMessageId, from: await resolveSendFrom(sendAs, agentEmail) })
|
||||
if (r && r.threadId) conv.threadId = r.threadId
|
||||
if (r && r.id) conv.lastGmailId = r.id
|
||||
conv.lastSubject = subject || conv.lastSubject
|
||||
const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '', toRaw: dest || '', ccRaw: cc || '', gmail_id: (r && r.id) || '' })
|
||||
const agentName = agentEmail ? await require('./auth').getDisplayNameByEmail(agentEmail).catch(() => '') : ''
|
||||
const m = addMessage(conv, { from: 'agent', text: (body || ''), via: 'email', html: html || '', agent: agentEmail || '', agentName, toRaw: dest || '', ccRaw: cc || '', gmail_id: (r && r.id) || '' })
|
||||
if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, m, subject).catch(() => {})
|
||||
conv.lastHumanDate = todayET(); saveToDisk()
|
||||
log(`Nouveau courriel sortant → ${dest} (gmail ${r && r.id})`)
|
||||
|
|
@ -1995,8 +2006,10 @@ async function handle (req, res, method, p, url) {
|
|||
const agentEmail = req.headers['x-authentik-email']
|
||||
const from = agentEmail ? 'agent' : 'customer'
|
||||
const isEmail = conv.channel === 'email'
|
||||
// Nom complet canonique de l'agent (Authentik) → le fil montre QUEL collègue a répondu/transféré.
|
||||
const agentName = from === 'agent' ? await require('./auth').getDisplayNameByEmail(agentEmail).catch(() => '') : ''
|
||||
if (from === 'agent') conv.lastHumanDate = todayET() // humain (agent OPS) actif aujourd'hui → l'IA se tait ce jour (posé AVANT addMessage pour être persisté par son saveToDisk)
|
||||
const msg = addMessage(conv, { from, text, via: isEmail ? 'email' : 'web', type: media ? 'image' : 'text', media, html, agent: agentEmail || '', attachments, sendAs: body.sendAs || '', toRaw: (from === 'agent' && isEmail) ? (toOverride || conv.email || '') : '', ccRaw: cc })
|
||||
const msg = addMessage(conv, { from, text, via: isEmail ? 'email' : 'web', type: media ? 'image' : 'text', media, html, agent: agentEmail || '', agentName, attachments, sendAs: body.sendAs || '', toRaw: (from === 'agent' && isEmail) ? (toOverride || conv.email || '') : '', ccRaw: cc })
|
||||
if (from === 'agent') { msg.cc = cc; if (toOverride) msg.replyToOverride = toOverride; msg.notifiedVia = await notifyCustomer(conv, msg) } // email → envoi HTML dans le fil (To/Cc inclus) ; sinon push/SMS
|
||||
if (from === 'customer' && conv.customer && !isEmail) triggerAgent(conv) // pas d'auto-réponse IA sur courriel
|
||||
if (isEmail && conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, msg, conv.lastSubject).catch(() => {}) // chaîne : alimente le ticket lié (Issue ERPNext)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ const cfg = require('./config')
|
|||
const { log, json, parseBody, erpFetch, readJsonFile } = require('./helpers')
|
||||
// Prédicat « ce tech a-t-il la compétence ? » PARTAGÉ (même sémantique que capacityByDay/solveur). Le champ ERP `skills`
|
||||
// (ou `_user_tags`) est une CSV → techHasSkill l'accepte telle quelle. SOURCE UNIQUE : lib/skill-resolver.js.
|
||||
const { techHasSkill } = require('./skill-resolver')
|
||||
const { techHasSkill, techHasSkills } = require('./skill-resolver')
|
||||
// Techs archivés (store hub partagé avec roster.js) → exclus des créneaux + occupation. Lu à chaud (change rarement).
|
||||
const archivedTechSet = () => new Set((readJsonFile('/app/data/archived_techs.json', []) || []).map(String))
|
||||
|
||||
|
|
@ -207,12 +207,12 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
|
|||
const shift = winBy[tech.technician_id + '|' + dateStr] || winBy[tech.name + '|' + dateStr]
|
||||
if (!shift) continue
|
||||
|
||||
// Day's pinned jobs (only those with a real start_time — floating jobs
|
||||
// without a time are ignored since we don't know when they'll land).
|
||||
// Day's pinned jobs (only those with a real start_time) — servent à découper les trous.
|
||||
// Match par technician_id OU docname (les jobs legacy portent parfois l'un ou l'autre).
|
||||
const belongs = (j) => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) &&
|
||||
j.scheduled_date === dateStr && !(ignoreReserved && j.job_type === 'Réservation')
|
||||
const dayJobs = allJobs
|
||||
.filter(j => j.assigned_tech === tech.technician_id &&
|
||||
j.scheduled_date === dateStr && j.start_time &&
|
||||
!(ignoreReserved && j.job_type === 'Réservation')) // mode urgence/réparation → les blocs RÉSERVÉS (soft) ne bloquent pas
|
||||
.filter(j => belongs(j) && j.start_time)
|
||||
.map(j => {
|
||||
const s = timeToHours(j.start_time)
|
||||
return {
|
||||
|
|
@ -223,6 +223,16 @@ async function suggestSlots ({ duration_h = 1, latitude, longitude, after_date,
|
|||
})
|
||||
.sort((a, b) => a.start_h - b.start_h)
|
||||
|
||||
// « Humainement possible » : un tech ne peut pas dépasser son quart. Les jobs assignés SANS heure fixée
|
||||
// (legacy osTicket) occupent quand même la journée → on retranche leur charge + celle déjà placée ; si la
|
||||
// capacité restante ne suffit pas pour cette durée, PAS de créneau ce jour (sinon on suroffre un tech déjà plein).
|
||||
const shiftH = shift.end_h - shift.start_h
|
||||
const clampD = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
|
||||
const timedBusy = dayJobs.reduce((a, j) => a + clampD(j), 0)
|
||||
const untimedLoad = allJobs.filter(j => belongs(j) && !j.start_time)
|
||||
.reduce((a, j) => a + (parseFloat(j.duration_h) || 1), 0)
|
||||
if (timedBusy + untimedLoad + duration > shiftH + 0.01) continue
|
||||
|
||||
// Build gaps bounded by shift_start/end (shift = quart réel du tech ce jour, résolu ci-dessus).
|
||||
const gaps = []
|
||||
let cursor = shift.start_h, prevCoords = homeCoords
|
||||
|
|
@ -307,7 +317,8 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
|||
const baseDate = after_date || todayET()
|
||||
const span = Math.max(1, Math.min(42, parseInt(days, 10) || SLOT_HORIZON_DAYS)) // jusqu'à 6 semaines (calendrier mois de l'horaire tech)
|
||||
const dates = Array.from({ length: span }, (_, i) => dateAddDays(baseDate, i))
|
||||
const wantSkill = String(skill || '').trim().toLowerCase()
|
||||
// `skill` accepte UNE compétence OU plusieurs en CSV (« réparation,sans-fil ») → le tech doit les avoir TOUTES.
|
||||
const wantSkills = String(skill || '').split(',').map(s => s.trim()).filter(Boolean)
|
||||
|
||||
const [techRes, jobRes, tplRes, shiftRes, availRes] = await Promise.all([
|
||||
erpFetch(`/api/resource/Dispatch Technician?fields=${encodeURIComponent(JSON.stringify([
|
||||
|
|
@ -325,7 +336,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
|||
erpFetch(`/api/resource/Tech Availability?filters=${encodeURIComponent(JSON.stringify([['status', '=', 'Approuvé'], ['from_date', '<=', dates[dates.length - 1]], ['to_date', '>=', dates[0]]]))}&fields=${encodeURIComponent(JSON.stringify(['technician', 'from_date', 'to_date']))}&limit_page_length=500`),
|
||||
])
|
||||
if (techRes.status !== 200) throw new Error('Failed to fetch technicians')
|
||||
const hasSkill = (t) => techHasSkill(t.skills || t._user_tags, wantSkill)
|
||||
const hasSkill = (t) => techHasSkills(t.skills || t._user_tags, wantSkills)
|
||||
const techs = (() => { const _arch = archivedTechSet(); return (techRes.data.data || []).filter(t => t.status !== 'unavailable' && t.status !== 'En pause' && !_arch.has(t.technician_id) && !_arch.has(t.name) && hasSkill(t)) })()
|
||||
const allJobs = jobRes.status === 200 ? (jobRes.data.data || []) : []
|
||||
// Congés approuvés (Tech Availability) → jours « off » dans l'occupation (aligné sur suggestSlots + roster).
|
||||
|
|
@ -354,21 +365,37 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
|||
if (absent) return { date: dateStr, dow, off: true, reason: 'absence', occupancy: null }
|
||||
if (!shift) return { date: dateStr, dow, off: true, reason: (dow === 0 || dow === 6) ? 'weekend' : 'no_shift', occupancy: null }
|
||||
const shiftH = shift.end_h - shift.start_h
|
||||
const dayJobs = allJobs
|
||||
.filter(j => j.assigned_tech === tech.technician_id && j.scheduled_date === dateStr && j.start_time)
|
||||
.map(j => { const s = timeToHours(j.start_time); return { start_h: s, end_h: s + (parseFloat(j.duration_h) || 1), reserved: j.job_type === 'Réservation' } })
|
||||
.sort((a, b) => a.start_h - b.start_h)
|
||||
// Match par technician_id OU docname (comme les quarts/congés) : les jobs legacy portent parfois l'un ou l'autre.
|
||||
const dayJobsRaw = allJobs
|
||||
.filter(j => (j.assigned_tech === tech.technician_id || j.assigned_tech === tech.name) && j.scheduled_date === dateStr)
|
||||
.map(j => { const s = j.start_time ? timeToHours(j.start_time) : null; const dur = parseFloat(j.duration_h) || 1; return { start_h: s, dur, end_h: s != null ? s + dur : null, reserved: j.job_type === 'Réservation' } })
|
||||
const timed = dayJobsRaw.filter(j => j.start_h != null).sort((a, b) => a.start_h - b.start_h)
|
||||
const untimed = dayJobsRaw.filter(j => j.start_h == null) // jobs assignés SANS heure fixée (legacy osTicket) — occupent quand même la journée
|
||||
const clamp = (j) => Math.max(0, Math.min(j.end_h, shift.end_h) - Math.max(j.start_h, shift.start_h))
|
||||
const busyH = dayJobs.reduce((acc, j) => acc + clamp(j), 0)
|
||||
const blocks = dayJobs.map(j => ({
|
||||
const timedBusy = timed.reduce((acc, j) => acc + clamp(j), 0)
|
||||
const untimedBusy = untimed.reduce((acc, j) => acc + j.dur, 0)
|
||||
const busyH = timedBusy + untimedBusy // ⇐ correctif : les jobs sans heure comptent (avant : ignorés ⇒ « 40 h libre » alors que plein)
|
||||
const blocks = timed.map(j => ({
|
||||
start: hoursToTime(Math.max(j.start_h, shift.start_h)), end: hoursToTime(Math.min(j.end_h, shift.end_h)),
|
||||
top: shiftH > 0 ? +Math.max(0, (Math.max(j.start_h, shift.start_h) - shift.start_h) / shiftH).toFixed(3) : 0,
|
||||
height: shiftH > 0 ? +Math.max(0, clamp(j) / shiftH).toFixed(3) : 0,
|
||||
reserved: j.reserved,
|
||||
}))
|
||||
// Jobs sans heure : empilés à partir de la charge horaire déjà placée, marqués « untimed » (rendu hachuré côté SPA).
|
||||
let cum = timedBusy
|
||||
for (const j of untimed) {
|
||||
blocks.push({
|
||||
start: null, end: null,
|
||||
top: shiftH > 0 ? +Math.min(1, cum / shiftH).toFixed(3) : 0,
|
||||
height: shiftH > 0 ? +Math.max(0.03, j.dur / shiftH).toFixed(3) : 0,
|
||||
reserved: j.reserved, untimed: true,
|
||||
})
|
||||
cum += j.dur
|
||||
}
|
||||
return {
|
||||
date: dateStr, dow, off: false, shift_start: hoursToTime(shift.start_h), shift_end: hoursToTime(shift.end_h),
|
||||
shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1), jobs: dayJobs.length,
|
||||
shift_h: +shiftH.toFixed(1), busy_h: +busyH.toFixed(1), free_h: +Math.max(0, shiftH - busyH).toFixed(1),
|
||||
jobs: dayJobsRaw.length, untimed_jobs: untimed.length,
|
||||
occupancy: shiftH > 0 ? Math.min(1, +(busyH / shiftH).toFixed(2)) : 0, blocks,
|
||||
}
|
||||
})
|
||||
|
|
@ -382,7 +409,7 @@ async function techOccupancy ({ after_date, days = SLOT_HORIZON_DAYS, skill = ''
|
|||
})
|
||||
// Le moins occupé d'abord = meilleurs candidats ; les techs sans aucun quart sur l'horizon en dernier.
|
||||
out.sort((a, b) => (a.occupancy == null ? 2 : a.occupancy) - (b.occupancy == null ? 2 : b.occupancy) || String(a.tech_name).localeCompare(b.tech_name))
|
||||
return { start: baseDate, days: span, dates, skill: wantSkill, techs: out }
|
||||
return { start: baseDate, days: span, dates, skill: wantSkills.join(','), skills: wantSkills, techs: out }
|
||||
}
|
||||
|
||||
// Numéro de job STANDARDISÉ « YYYYMMDD-XXX » (compteur PAR JOUR). Le nom du Dispatch Job = ce champ (autoname field:ticket_id).
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ const SEED_CONTENT = {
|
|||
name: 'Targo fête ses 20 ans',
|
||||
tagline: "Toute l'équipe de Targo est heureuse de vous inviter à notre grande fête d'anniversaire !",
|
||||
when: '1ᵉʳ août, de 10 h à 15 h',
|
||||
address: 'À nos bureaux : 1867 chemin de la rivière, Ste-Clotilde',
|
||||
body: "Joignez-vous à nous pour une journée de plaisir, de rencontres et de célébration en compagnie des employés de Targo et de leurs familles.",
|
||||
program: [
|
||||
{ icon: '🚚', label: 'Food truck', sub: 'repas inclus' },
|
||||
|
|
@ -139,6 +140,7 @@ const SEED_CONTENT = {
|
|||
name: 'Targo turns 20!',
|
||||
tagline: 'The whole Targo team is happy to invite you to our big anniversary celebration!',
|
||||
when: 'August 1st, 10 a.m. to 3 p.m.',
|
||||
address: 'At our offices: 1867 chemin de la rivière, Ste-Clotilde',
|
||||
body: 'Join us for a day of fun, meeting people and celebrating alongside the Targo team and their families.',
|
||||
program: [
|
||||
{ icon: '🚚', label: 'Food truck', sub: 'meal included' },
|
||||
|
|
@ -224,6 +226,7 @@ function sanitizeLangContent (c) {
|
|||
name: String(c.name || '').slice(0, 160),
|
||||
tagline: String(c.tagline || '').slice(0, 300),
|
||||
when: String(c.when || '').slice(0, 120),
|
||||
address: String(c.address || '').slice(0, 200),
|
||||
body: String(c.body || '').slice(0, 1200),
|
||||
program: sanitizeProgram(c.program),
|
||||
program_line: String(c.program_line || '').slice(0, 200),
|
||||
|
|
@ -299,9 +302,14 @@ function keyFor (resolved, form) {
|
|||
}
|
||||
|
||||
// ── Lien perso + courriel d'invitation ─────────────────────────────────────
|
||||
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24) {
|
||||
// `lang` (optionnel) → ajouté en ?lang= EXPLICITE sur l'URL : la page RSVP priorise TOUJOURS ce
|
||||
// paramètre de requête sur la langue du jeton (cf handle() : `if (!url.searchParams.get('lang') && p.lang)`).
|
||||
// Sans ça, la version EN du courriel pointait vers la même URL que la FR (défaut FR). Nécessaire
|
||||
// même si le modèle éditable (Unlayer) ne permet pas de fixer un href différent par langue.
|
||||
function rsvpLink (eventId, customerId, name, email, ttlHours = 60 * 24, lang = '') {
|
||||
const tok = generateCustomerToken(customerId, name, email, ttlHours)
|
||||
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}`
|
||||
const l = lang ? `&lang=${encodeURIComponent(normLang(lang))}` : ''
|
||||
return `${pub()}/rsvp/${encodeURIComponent(eventId)}?t=${encodeURIComponent(tok)}${l}`
|
||||
}
|
||||
function inviteSubject (eventId, lang) {
|
||||
const e = getEvent(eventId); if (!e) return ''
|
||||
|
|
@ -312,7 +320,7 @@ function inviteTemplateVars (e, lang, name, rsvpUrl) {
|
|||
const t = e[lang]
|
||||
return {
|
||||
firstname: firstName(name), name: name || '', rsvp_url: rsvpUrl, gift_url: rsvpUrl, // gift_url = alias (templates cadeau réutilisables)
|
||||
event_name: t.name, tagline: t.tagline, when: t.when, body: t.body, closing: t.closing,
|
||||
event_name: t.name, tagline: t.tagline, when: t.when, address: t.address, body: t.body, closing: t.closing,
|
||||
year: String(new Date().getFullYear()), lang,
|
||||
}
|
||||
}
|
||||
|
|
@ -341,16 +349,17 @@ function inviteEmailFestive (e, lang, name, rsvpUrl) {
|
|||
+ `<body style="margin:0;background:#eef4f8;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;color:#1e293b">`
|
||||
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#eef4f8"><tr><td align="center" style="padding:30px 14px">`
|
||||
+ `<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;background:#fff;border-radius:18px;overflow:hidden;border:1px solid #e2e8f0">`
|
||||
+ `<tr><td style="height:10px;background:#1a86c7;font-size:0;line-height:0"> </td></tr>`
|
||||
+ `<tr><td style="height:10px;background:#fff;font-size:0;line-height:0"> </td></tr>`
|
||||
+ `<tr><td align="center" style="padding:22px 30px 0">`
|
||||
+ `<div style="font-size:24px;letter-spacing:6px;line-height:1">🎈🎉🎈</div>`
|
||||
+ (badge ? `<div style="display:inline-block;background:#21a34a;color:#fff;font-weight:800;font-size:12px;letter-spacing:1.5px;padding:6px 16px;border-radius:999px;margin:10px 0 8px">🎉 ${esc(badge)} 🎉</div><br>` : '<div style="height:10px"></div>')
|
||||
+ `<img src="${LOGO}" alt="TARGO" width="150" style="width:150px;max-width:150px;height:auto;display:inline-block;border:0;margin:2px 0"></td></tr>`
|
||||
+ `<img src="${LOGO}" alt="TARGO" width="150" style="width:150px;max-width:150px;height:auto;display:inline-block;border:0;margin:2px 0 22px"></td></tr>`
|
||||
+ `<tr><td align="center" style="padding:8px 26px 0"><h1 style="margin:6px 0 4px;font-size:27px;font-weight:800;color:#1f2937;line-height:1.15">${esc(t.name)} 🎉</h1>`
|
||||
+ `<p style="margin:0;font-size:15px;font-weight:700;color:#21a34a;line-height:1.4">${esc(t.tagline)}</p></td></tr>`
|
||||
+ `<tr><td style="padding:16px 34px 4px"><p style="margin:0 0 10px;font-size:15px;line-height:1.6;color:#475569">${esc(t.greet(firstName(name)) || '')}</p>`
|
||||
+ `<p style="margin:0;font-size:15px;line-height:1.6;color:#475569">${esc(t.body)}</p></td></tr>`
|
||||
+ (t.when ? `<tr><td align="center" style="padding:16px 30px 6px"><table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="background:#1f2937;border-radius:12px;padding:13px 24px;color:#fff;font-size:18px;font-weight:800">📅 ${esc(t.when)}</td></tr></table></td></tr>` : '')
|
||||
+ (t.address ? `<tr><td align="center" style="padding:2px 30px 6px"><p style="margin:0;font-size:14px;font-weight:600;color:#374151">📍 ${esc(t.address)}</p></td></tr>` : '')
|
||||
+ ((t.program || []).length ? `<tr><td style="padding:14px 18px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${prog}</tr></table></td></tr>` : '')
|
||||
+ (t.program_line ? `<tr><td align="center" style="padding:2px 30px 8px"><p style="margin:0;font-size:13px;font-style:italic;color:#94a3b8">${esc(t.program_line)}</p></td></tr>` : '')
|
||||
+ (t.limited ? `<tr><td style="padding:6px 30px 4px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;border:1px solid #e5e7eb;border-radius:12px"><tr><td style="padding:12px 16px;font-size:14px;line-height:1.6;color:#374151">⏳ ${esc(t.limited)}</td></tr></table></td></tr>` : '')
|
||||
|
|
@ -420,30 +429,97 @@ function loadSendAttachments (event, lang) {
|
|||
return out
|
||||
}
|
||||
|
||||
// Normalise les pièces jointes (buffers) au format attendu par le canal.
|
||||
function attachmentPayload (channel, atts) {
|
||||
return channel === 'gmail'
|
||||
? atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') }))
|
||||
: atts.map(a => ({ filename: a.filename, contentType: a.mime, content: a.buffer }))
|
||||
}
|
||||
// Envoi bas niveau d'un courriel d'invitation. customId (Mailjet) = suivi ouvertures/clics.
|
||||
async function sendInviteMessage ({ channel, to, subject, html, from, attachments, customId }) {
|
||||
if (channel === 'gmail') { const r = await require('./gmail').sendMessage({ to, subject, html, from: from || undefined, attachments }); return { ok: !!r, id: r && r.id } }
|
||||
const headers = customId ? { 'X-MJ-CustomID': customId } : undefined
|
||||
const info = await require('./email').sendEmail({ to, subject, html, from: from || undefined, attachments, headers })
|
||||
return { ok: !!info, id: info && info.messageId, error: info ? null : ((require('./email').getLastError() || {}).message || 'send_failed') }
|
||||
}
|
||||
|
||||
// ── Envoi TEST (déclenché par le staff depuis l'admin ; jamais automatique) ──
|
||||
async function sendTest ({ eventId, emails, channel = 'mailjet', from = '', lang = 'fr', name = '' }) {
|
||||
const event = getEvent(eventId)
|
||||
const atts = loadSendAttachments(event, lang) // pièces jointes de la langue testée (+ 'both')
|
||||
const atts = attachmentPayload(channel, loadSendAttachments(event, lang)) // pièces jointes de la langue testée (+ 'both')
|
||||
const results = []
|
||||
for (const em of emails) {
|
||||
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24)
|
||||
const link = rsvpLink(eventId, 'TEST-' + em, name || 'Test', em, 24, lang)
|
||||
const html = inviteEmail(eventId, lang, name, link)
|
||||
const subject = '[TEST] ' + inviteSubject(eventId, lang)
|
||||
try {
|
||||
let ok
|
||||
if (channel === 'gmail') {
|
||||
const attachments = atts.map(a => ({ filename: a.filename, mime: a.mime, content: a.buffer.toString('base64') }))
|
||||
const r = await require('./gmail').sendMessage({ to: em, subject, html, from: from || undefined, attachments }); ok = !!r
|
||||
} else {
|
||||
const attachments = atts.map(a => ({ filename: a.filename, contentType: a.mime, content: a.buffer }))
|
||||
ok = await require('./email').sendEmail({ to: em, subject, html, from: from || undefined, attachments })
|
||||
}
|
||||
results.push({ email: em, ok: !!ok })
|
||||
} catch (e) { results.push({ email: em, ok: false, error: e.message }) }
|
||||
try { const r = await sendInviteMessage({ channel, to: em, subject, html, from, attachments: atts }); results.push({ email: em, ok: r.ok, error: r.error }) }
|
||||
catch (e) { results.push({ email: em, ok: false, error: e.message }) }
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// ── Envoi de MASSE (blast d'invitations) ───────────────────────────────────
|
||||
// Crée un enregistrement CAMPAGNE (suivi Mailjet via X-MJ-CustomID `<id>:<i>` → webhook campagne existant,
|
||||
// report.csv, /campaigns list/détail, SSE) MAIS events fait l'envoi lui-même : lien rsvp_url PERSONNEL par
|
||||
// destinataire + pièces jointes par langue + gabarit festif/Unlayer. Le worker campagne ne gère ni rsvp_url
|
||||
// ni pièces jointes ni HTML festif → on ne le réutilise pas, seulement son format d'enregistrement.
|
||||
function massSend (eventId, { channel = 'mailjet', from = '' } = {}) {
|
||||
const event = getEvent(eventId); if (!event) return { error: 'event_not_found' }
|
||||
const aud = loadAudienceList(eventId)
|
||||
const recipients = (aud.recipients || []).filter(r => EMAIL_RE.test(r.email || ''))
|
||||
if (!recipients.length) return { error: 'empty_list' }
|
||||
const campaigns = require('./campaigns')
|
||||
const id = campaigns.newCampaignId()
|
||||
const ch = channel === 'gmail' ? 'gmail' : 'mailjet'
|
||||
const campaign = {
|
||||
id,
|
||||
name: `Invitation — ${event.fr.name}`,
|
||||
created_at: new Date().toISOString(),
|
||||
status: 'sending',
|
||||
params: { channel: ch, from: from || '', type: 'event', event_id: eventId, template: event.email_template || 'festive' },
|
||||
recipients: recipients.map(r => ({ email: r.email, firstname: r.firstname || '', lastname: r.lastname || '', language: normLang(r.language), customer_id: r.customer_id || '', source: r.source || '', status: 'pending' })),
|
||||
}
|
||||
campaigns.saveCampaign(campaign)
|
||||
setImmediate(() => sendEventCampaignAsync(id, eventId, ch, from).catch(e => log('event blast async: ' + e.message)))
|
||||
return { campaign_id: id, count: campaign.recipients.length }
|
||||
}
|
||||
|
||||
async function sendEventCampaignAsync (id, eventId, channel, from) {
|
||||
const campaigns = require('./campaigns')
|
||||
const c = campaigns.loadCampaign(id); if (!c) return
|
||||
const event = getEvent(eventId); if (!event) return
|
||||
let sse = null; try { sse = require('./sse') } catch { /* SSE optionnel */ }
|
||||
const topic = 'campaign:' + id
|
||||
const bcast = (ev, data) => { try { sse && sse.broadcast(topic, ev, data) } catch { /* */ } }
|
||||
const payloadByLang = { fr: attachmentPayload(channel, loadSendAttachments(event, 'fr')), en: attachmentPayload(channel, loadSendAttachments(event, 'en')) }
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
bcast('campaign-status', { id, status: 'sending' })
|
||||
for (let i = 0; i < c.recipients.length; i++) {
|
||||
const r = c.recipients[i]
|
||||
if (r.status !== 'pending') continue
|
||||
const lang = normLang(r.language)
|
||||
const name = ((r.firstname || '') + ' ' + (r.lastname || '')).trim()
|
||||
const rsvpUrl = rsvpLink(eventId, r.customer_id || ('x-' + r.email), name, r.email, 60 * 24, lang)
|
||||
r.rsvp_url = rsvpUrl
|
||||
const html = inviteEmail(eventId, lang, name, rsvpUrl)
|
||||
const subject = inviteSubject(eventId, lang)
|
||||
const customId = id + ':' + i
|
||||
try {
|
||||
const res = await sendInviteMessage({ channel, to: r.email, subject, html, from, attachments: payloadByLang[lang] || [], customId })
|
||||
if (res.ok) { r.status = 'sent'; r.sent_at = new Date().toISOString(); if (channel !== 'gmail') { r.mailjet_custom_id = customId; if (res.id) r.mailjet_uuid = res.id } }
|
||||
else { r.status = 'failed'; r.error = res.error || 'send_failed' }
|
||||
} catch (e) { r.status = 'failed'; r.error = e.message }
|
||||
campaigns.saveCampaign(c)
|
||||
bcast('recipient-update', { i, recipient: r })
|
||||
if (i < c.recipients.length - 1) await sleep(600) // throttle (comme le worker campagne)
|
||||
}
|
||||
c.status = 'completed'; c.send_completed_at = new Date().toISOString()
|
||||
campaigns.saveCampaign(c)
|
||||
const sent = c.recipients.filter(x => x.status === 'sent').length
|
||||
log(`Event blast ${eventId} → campaign ${id} : ${sent}/${c.recipients.length} envoyés (${channel})`)
|
||||
bcast('campaign-done', { id, counters: c.counters })
|
||||
}
|
||||
|
||||
// ── Audience de l'envoi de masse : liste clients filtrée OU import CSV ──────
|
||||
function splitName (full) {
|
||||
const parts = String(full || '').trim().split(/\s+/).filter(Boolean)
|
||||
|
|
@ -682,7 +758,7 @@ body{margin:0;font-family:-apple-system,'Segoe UI',Roboto,Helvetica,Arial,sans-s
|
|||
background:var(--g);color:#fff;font-weight:800;line-height:1;box-shadow:0 8px 20px rgba(33,163,74,.3);margin-bottom:12px}
|
||||
.badge b{font-size:1.35rem}.badge span{font-size:.6rem;letter-spacing:1px}
|
||||
.badge.emoji{font-size:2rem}
|
||||
.logo{height:34px;width:auto;display:block;margin:0 auto 14px}
|
||||
.logo{height:34px;width:auto;display:block;margin:0 auto 34px}
|
||||
h1{font-size:1.7rem;font-weight:800;letter-spacing:-.5px;margin:0 0 6px;color:var(--ink)}
|
||||
.tag{color:var(--g);font-weight:700;font-size:1.02rem;margin:0 0 4px}
|
||||
.card{background:#fff;border:1px solid #eef2f0;border-radius:20px;box-shadow:0 16px 44px rgba(23,58,94,.09);padding:26px 22px;margin-bottom:16px}
|
||||
|
|
@ -690,6 +766,7 @@ p{line-height:1.6;font-size:1rem;margin:0 0 12px;color:#334155}
|
|||
.when{display:flex;align-items:center;justify-content:center;gap:10px;background:var(--ink);color:#fff;font-weight:700;
|
||||
font-size:1.12rem;border-radius:14px;padding:13px 18px;margin:4px 0 16px}
|
||||
.when .cal{font-size:1.3rem}
|
||||
.addr{display:flex;align-items:center;justify-content:center;gap:6px;color:#374151;font-weight:600;font-size:.92rem;margin:-8px 0 16px;text-align:center}
|
||||
.prog-wrap{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;margin:6px 0 4px}
|
||||
.prog{flex:1 1 90px;max-width:110px;text-align:center;background:#f8fafc;border:1px solid #eef2f6;border-radius:14px;padding:12px 6px}
|
||||
.prog-ic{font-size:1.7rem;line-height:1}
|
||||
|
|
@ -732,6 +809,7 @@ button:hover{filter:brightness(1.05)}button:active{transform:translateY(1px)}but
|
|||
|
||||
<div id="invite" class="card">
|
||||
${t.when ? `<div class="when"><span class="cal">📅</span><span>${esc(t.when)}</span></div>` : ''}
|
||||
${t.address ? `<div class="addr">📍 ${esc(t.address)}</div>` : ''}
|
||||
<p>${esc(t.body)}</p>
|
||||
${program ? `<div class="prog-wrap">${program}</div>` : ''}
|
||||
${t.program_line ? `<p style="text-align:center;font-style:italic;color:var(--muted);margin-top:12px">${esc(t.program_line)}</p>` : ''}
|
||||
|
|
@ -1003,7 +1081,14 @@ async function handle (req, res, method, path, url) {
|
|||
log(`Event invite TEST — ${eventId} · ${channel} · ${results.filter(r => r.ok).length}/${results.length} ok`)
|
||||
return json(res, 200, { ok: results.every(r => r.ok), test: true, results })
|
||||
}
|
||||
return json(res, 400, { error: 'mass_send_not_enabled', message: "Envoi de masse pas encore activé — choisir l'audience d'abord." })
|
||||
// Envoi de MASSE réel (gaté côté UI par une confirmation ; ici on exige mass:true).
|
||||
if (b.mass) {
|
||||
const r = massSend(eventId, { channel, from: b.from })
|
||||
if (r.error) return json(res, 400, { error: r.error })
|
||||
log(`Event invite MASS — ${eventId} · ${channel} · ${r.count} destinataire(s) → campagne ${r.campaign_id}`)
|
||||
return json(res, 202, { ok: true, campaign_id: r.campaign_id, count: r.count })
|
||||
}
|
||||
return json(res, 400, { error: 'bad_request' })
|
||||
}
|
||||
// Staff : POST /events/<id>/audience {mode:'filter'|'csv'|'manual', filters, csv} → APERÇU (ne persiste rien)
|
||||
mm = path.match(/^\/events\/([A-Za-z0-9_-]+)\/audience$/)
|
||||
|
|
@ -1048,4 +1133,4 @@ async function handle (req, res, method, path, url) {
|
|||
} catch (e) { log('events handle: ' + e.message); return json(res, 500, { error: e.message }) }
|
||||
}
|
||||
|
||||
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary }
|
||||
module.exports = { handle, getEvent, listEvents, listRsvps, deleteRsvp, rsvpLink, inviteEmail, inviteSubject, sendTest, massSend, sendEventCampaignAsync, matchAndValidate, resolveAudience, parseAudienceCsv, page, normLang, addAttachment, removeAttachment, loadSendAttachments, headcountOf, audienceListAdd, audienceListRemove, audienceListClear, loadAudienceList, audienceSummary }
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@
|
|||
* Routes : GET /dispatch/legacy-sync/preview (dry-run, 0 écriture) · POST /dispatch/legacy-sync/run
|
||||
* Récurrence : startSync() (setInterval, cf. server.js), désactivable via LEGACY_DISPATCH_SYNC=off.
|
||||
*
|
||||
* LIEN FICHE CLIENT : chaque import (re)lie aussi l'Issue ERPNext sous-jacente (legacy_ticket_id) à son
|
||||
* `customer` → le ticket legacy remonte dans la fiche client (ClientDetailPage). Rattrapage du backlog :
|
||||
* GET/POST /dispatch/legacy-sync/backfill-issues (dry-run GET · applique POST). Kill-switch LEGACY_DISPATCH_LINK_ISSUES=off.
|
||||
*
|
||||
* Pré-requis : champ Custom Field `legacy_ticket_id` sur Dispatch Job
|
||||
* (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py).
|
||||
*/
|
||||
|
|
@ -342,8 +346,16 @@ function stripHtml (html, max = 1500) {
|
|||
|
||||
async function fetchTargoTickets () {
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
// PARENT/CHILD legacy : un ticket ENFANT (`t.parent` > 0) porte souvent account_id/delivery_id = 0 — le compte
|
||||
// et l'adresse vivent sur le ticket PARENT. On résout donc le compte/adresse EFFECTIFS via COALESCE(propre, parent)
|
||||
// (jointure `pt`). Sans ça, l'enfant ne se rattache à AUCUN client → n'apparaît pas dans la fiche (bug signalé).
|
||||
// Le contact (a.*), l'adresse de service (dv.*) et resolveOrCreateCustomer (via l'alias account_id) utilisent tous
|
||||
// l'account effectif → aucun changement requis dans buildJob. Un seul niveau de remontée (parent direct).
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
|
||||
`SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id,
|
||||
COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) AS account_id,
|
||||
COALESCE(NULLIF(t.delivery_id, 0), NULLIF(pt.delivery_id, 0)) AS delivery_id,
|
||||
t.parent, t.waiting_for, t.participant, t.followed_by, t.important,
|
||||
t.date_create, t.last_update,
|
||||
a.first_name, a.last_name, a.company, a.email, a.cell, a.tel_home, a.address1, a.address2, a.city, a.state, a.zip,
|
||||
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.placemarks_id AS dv_pmid, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
|
||||
|
|
@ -353,12 +365,14 @@ async function fetchTargoTickets () {
|
|||
(SELECT mm3.msg FROM ticket_msg mm3
|
||||
WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
|
||||
FROM ticket t
|
||||
LEFT JOIN ticket pt ON pt.id = t.parent
|
||||
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
|
||||
LEFT JOIN account a ON a.id = t.account_id
|
||||
LEFT JOIN account a ON a.id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0))
|
||||
LEFT JOIN delivery dv ON dv.id = COALESCE(
|
||||
NULLIF(t.delivery_id, 0),
|
||||
(SELECT d2.id FROM delivery d2 WHERE t.account_id > 0 AND d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||||
(SELECT d3.id FROM delivery d3 WHERE t.account_id > 0 AND d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
|
||||
NULLIF(pt.delivery_id, 0),
|
||||
(SELECT d2.id FROM delivery d2 WHERE COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) > 0 AND d2.account_id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||||
(SELECT d3.id FROM delivery d3 WHERE COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) > 0 AND d3.account_id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) ORDER BY d3.id DESC LIMIT 1)
|
||||
)
|
||||
WHERE t.status = 'open' AND t.assign_to = ?
|
||||
ORDER BY t.due_date DESC`,
|
||||
|
|
@ -370,7 +384,20 @@ async function fetchTargoTickets () {
|
|||
// caches par run (vidés à chaque cycle) pour éviter les requêtes répétées
|
||||
let _custCache = new Map()
|
||||
let _slCache = new Map()
|
||||
function resetCaches () { _custCache = new Map(); _slCache = new Map() }
|
||||
let _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0 } // observabilité : comptes/SL créés + Issue (re)liées au client pendant le run (récits dans le résumé)
|
||||
function resetCaches () { _custCache = new Map(); _slCache = new Map(); _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0 } }
|
||||
// Auto-création des comptes manquants pendant l'import (par défaut ON — désactivable via LEGACY_DISPATCH_CREATE_CUSTOMERS=off).
|
||||
// Un ticket legacy dont le compte F n'existe pas encore côté ERPNext (ex. « Robert Usereau » dispatché mais introuvable dans OPS)
|
||||
// crée le Customer (chemin canonique legacy-sync) + une Service Location depuis l'adresse de service → la fiche devient trouvable et liable.
|
||||
const CREATE_MISSING = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_CREATE_CUSTOMERS || ''))
|
||||
// Lien Issue↔client : l'Issue ERPNext sous-jacente (créée par le cron `migrate_tickets.py` à 4h30) DOIT porter `customer`
|
||||
// pour que le ticket legacy remonte dans la fiche client — ClientDetailPage liste les Issue filtrées par `customer`
|
||||
// (useClientData.loadTickets). Son `customer` reste vide quand le Customer a été créé APRÈS la migration (par ce pont,
|
||||
// resolveOrCreateCustomer) : au moment du migrate, cust_map ne contenait pas encore le compte → Issue.customer = NULL.
|
||||
// On (re)lie donc l'Issue ici ; si elle manque encore (ticket plus récent que la dernière passe migrate), on la CRÉE
|
||||
// (minimale). Idempotent : migrate_tickets.py skippe tout legacy_ticket_id déjà présent → aucun doublon. Kill-switch : LEGACY_DISPATCH_LINK_ISSUES=off.
|
||||
const LINK_ISSUES = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_LINK_ISSUES || ''))
|
||||
const ISSUE_COMPANY = process.env.ERP_COMPANY || 'TARGO' // aligné sur migrate_tickets.py (COMPANY = "TARGO")
|
||||
|
||||
async function resolveCustomer (accountId) {
|
||||
if (!accountId) return null
|
||||
|
|
@ -392,6 +419,35 @@ async function resolveServiceLocation (custName, city) {
|
|||
if (city) { const hit = list.find(l => norm(l.city) === norm(city)); if (hit) return hit } // préfère la ville qui matche
|
||||
return list[0]
|
||||
}
|
||||
// Résout le Customer par legacy_account_id ; s'il MANQUE et qu'on n'est pas en dry-run, le CRÉE via le chemin canonique
|
||||
// (legacy-sync.createCustomersByIds → mapAccount depuis le compte F : nom/type/groupe/courriel/mobile). Idempotent (re-run = 0 doublon).
|
||||
async function resolveOrCreateCustomer (accountId, dryRun) {
|
||||
let c = await resolveCustomer(accountId)
|
||||
if (c || !accountId || dryRun || !CREATE_MISSING) return c
|
||||
try {
|
||||
const ls = require('./legacy-sync') // lazy require (pas de cycle : legacy-sync ne require pas ce module)
|
||||
const r = await ls.createCustomersByIds({ ids: [Number(accountId)], confirm: 'F-WINS', limit: 1 })
|
||||
if (r && r.created) { _custCache.delete(String(accountId)); c = await resolveCustomer(accountId); if (c) _createStats.customers++ }
|
||||
} catch (e) { log('resolveOrCreateCustomer: échec création compte ' + accountId + ' — ' + (e && e.message)) }
|
||||
return c
|
||||
}
|
||||
// Crée une Service Location pour un client dépourvu d'adresse de service en ERPNext, depuis l'adresse legacy (delivery > facturation).
|
||||
// Best-effort : si le doctype exige des champs qu'on ne fournit pas, erp.create renvoie {ok:false} → on log et on continue (le job reste lié au Customer).
|
||||
async function createServiceLocation (custName, { line, city, zip, lat, lon, deliveryId } = {}, dryRun, { force = false } = {}) {
|
||||
if (!custName || dryRun || (!CREATE_MISSING && !force)) return null // `force` = appel EXPLICITE (ex. backfill one-time) → non soumis au kill-switch du chemin chaud
|
||||
const addrLine = String(line || '').trim()
|
||||
if (!addrLine) return null
|
||||
const doc = { customer: custName, address_line: addrLine.slice(0, 140), address_validation_status: 'pending' } // valeurs permises : pending/validated/manual/unmatched (PAS 'review')
|
||||
if (city) doc.city = String(city).slice(0, 140)
|
||||
const co = coord(lat, lon); if (co) { doc.latitude = co.lat; doc.longitude = co.lon }
|
||||
if (deliveryId) doc.legacy_delivery_id = String(deliveryId)
|
||||
try {
|
||||
const r = await erp.create('Service Location', doc)
|
||||
if (r && r.ok && r.name) { _slCache.delete(custName); _createStats.service_locations++; return { name: r.name, address_line: doc.address_line, city: doc.city || '', latitude: doc.latitude, longitude: doc.longitude } }
|
||||
log('createServiceLocation: échec ' + custName + ' — ' + ((r && r.error) || 'create'))
|
||||
} catch (e) { log('createServiceLocation: exception ' + custName + ' — ' + (e && e.message)) }
|
||||
return null
|
||||
}
|
||||
|
||||
// Construit le payload Dispatch Job à partir d'un ticket legacy (+ infos de matching).
|
||||
// VALIDATION PAR TÉLÉPHONE : un ticket sans compte (ex. « …Message vocal de 15143182129 ») peut être relié au client via le
|
||||
|
|
@ -441,9 +497,9 @@ async function townCenterFromSubject (subject) {
|
|||
return out ? { ...out } : null
|
||||
}
|
||||
|
||||
async function buildJob (t) {
|
||||
const cust = await resolveCustomer(t.account_id)
|
||||
const sl = cust ? await resolveServiceLocation(cust.name, t.city) : null
|
||||
async function buildJob (t, { dryRun = false } = {}) {
|
||||
const cust = await resolveOrCreateCustomer(t.account_id, dryRun) // crée le compte s'il manque (sauf dry-run)
|
||||
let sl = cust ? await resolveServiceLocation(cust.name, t.city) : null
|
||||
const jt = jobType(t.dept_id)
|
||||
const cname = cust ? cust.customer_name : ([t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || '')
|
||||
// Coords : la table legacy `delivery` (point de service réel, via ticket.delivery_id) est la
|
||||
|
|
@ -453,6 +509,17 @@ async function buildJob (t) {
|
|||
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
|
||||
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
|
||||
const addr = svcAddr || billAddr
|
||||
// Le client existe (ou vient d'être créé) mais n'a AUCUNE Service Location → on la crée depuis l'adresse de service legacy
|
||||
// (delivery de préférence, sinon facturation) pour que la fiche montre l'adresse et que le job pointe vers un lieu réel.
|
||||
if (cust && !sl && CREATE_MISSING && !dryRun) {
|
||||
const useSvc = !!(t.dv_addr)
|
||||
sl = await createServiceLocation(cust.name, {
|
||||
line: useSvc ? t.dv_addr : (t.address1 || svcAddr || billAddr),
|
||||
city: useSvc ? t.dv_city : t.city,
|
||||
zip: useSvc ? t.dv_zip : t.zip,
|
||||
lat: t.dv_lat, lon: t.dv_lon, deliveryId: t.delivery_id || t.dv_id || null,
|
||||
}, dryRun)
|
||||
}
|
||||
let subject = decodeEntities(t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
|
||||
const idTag = ' · #' + t.id // n° de ticket legacy visible dans le TITRE (référence croisée osTicket) — réserve la place pour ne pas le tronquer
|
||||
subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
|
||||
|
|
@ -513,7 +580,7 @@ async function buildJob (t) {
|
|||
if (pm) {
|
||||
const fa = [pm.dv.address1, pm.dv.city, pm.dv.zip].filter(Boolean).join(', ')
|
||||
if (!payload.address && fa) payload.address = fa.slice(0, 140)
|
||||
if (!payload.customer) { try { const c = await resolveCustomer(pm.account_id); if (c) payload.customer = c.name } catch (e) {} } // relie au compte OPS
|
||||
if (!payload.customer) { try { const c = await resolveOrCreateCustomer(pm.account_id, dryRun); if (c) payload.customer = c.name } catch (e) {} } // relie (ou crée) le compte OPS
|
||||
const dev = await resolveDevCoords(pool(), { pmid: pm.dv.pmid, dlat: pm.dv.lat, dlon: pm.dv.lon, address: fa })
|
||||
if (dev) { payload.latitude = dev.lat; payload.longitude = dev.lon; coordSrc = 'phone_' + dev.src }
|
||||
else { const g = await geocodeRQA(pm.dv.address1, pm.dv.zip, pm.dv.city); if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'phone_rqa' } }
|
||||
|
|
@ -523,7 +590,7 @@ async function buildJob (t) {
|
|||
const tc = await townCenterFromSubject(t.subject)
|
||||
if (tc) { payload.latitude = tc.lat; payload.longitude = tc.lon; coordSrc = 'ville_centre'; if (!payload.address) payload.address = tc.ville + ' (secteur — adresse à confirmer)' }
|
||||
}
|
||||
return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname, coords: !!coordSrc, coord_src: coordSrc, delivery_id: t.delivery_id || null }, dept: t.dept, addr }
|
||||
return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname, coords: !!coordSrc, coord_src: coordSrc, delivery_id: t.delivery_id || null, parent: Number(t.parent) || 0 }, dept: t.dept, addr }
|
||||
}
|
||||
|
||||
async function findExisting (legacyId) {
|
||||
|
|
@ -531,6 +598,44 @@ async function findExisting (legacyId) {
|
|||
return (r && r[0]) || null
|
||||
}
|
||||
|
||||
// Assure que l'Issue ERPNext (matchée par legacy_ticket_id) porte le `customer` → le ticket legacy apparaît dans la fiche
|
||||
// client (useClientData.loadTickets filtre les Issue par `customer`). Met à jour l'Issue si son customer est vide/différent ;
|
||||
// si l'Issue manque (ticket plus récent que la dernière passe `migrate_tickets.py`), la CRÉE (minimale). Idempotent
|
||||
// (re-run = 0 écriture sur les déjà-liées ; migrate_tickets.py skippe le legacy_ticket_id déjà présent → pas de doublon).
|
||||
// Best-effort : une erreur ici n'empêche PAS le Dispatch Job (on log et on continue — cf. createServiceLocation).
|
||||
async function ensureIssueCustomer (t, custName, { dryRun = false } = {}) {
|
||||
if (!LINK_ISSUES || !custName || !t || !t.id || dryRun) return null
|
||||
const legacyId = Number(t.id)
|
||||
if (!Number.isFinite(legacyId)) return null
|
||||
try {
|
||||
const rows = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'customer'], limit: 1 })
|
||||
const iss = rows && rows[0]
|
||||
if (iss) {
|
||||
if (iss.customer === custName) return { action: 'ok', issue: iss.name }
|
||||
const r = await erp.update('Issue', iss.name, { customer: custName }) // (re)lie — n'écrit que si vide/différent
|
||||
if (r && r.ok) { _createStats.issues_linked++; return { action: 'linked', issue: iss.name } }
|
||||
log('ensureIssueCustomer: update échec Issue ' + iss.name + ' (ticket#' + legacyId + ') — ' + ((r && r.error) || 'update'))
|
||||
return null
|
||||
}
|
||||
// Issue absente → create minimale. issue_type/priority OMIS volontairement (liens vers doctypes seedés → risque
|
||||
// LinkValidationError) : la fiche n'en a pas besoin pour lister le ticket. Le `subject`/`status`/`opening_date`
|
||||
// suffisent à un rendu correct (loadTickets trie par is_important desc, opening_date desc).
|
||||
const tkStatus = String(t.tk_status || 'open').toLowerCase()
|
||||
const doc = {
|
||||
subject: (decodeEntities(t.subject || '').trim() || ('Ticket #' + legacyId)).slice(0, 255),
|
||||
status: tkStatus === 'closed' ? 'Closed' : tkStatus === 'pending' ? 'On Hold' : 'Open',
|
||||
customer: custName,
|
||||
company: ISSUE_COMPANY,
|
||||
legacy_ticket_id: legacyId,
|
||||
}
|
||||
const od = tzDate(t.date_create); if (od) doc.opening_date = od
|
||||
const r = await erp.create('Issue', doc)
|
||||
if (r && r.ok && r.name) { _createStats.issues_created++; return { action: 'created', issue: r.name } }
|
||||
log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create'))
|
||||
} catch (e) { log('ensureIssueCustomer: exception ticket#' + legacyId + ' — ' + (e && e.message)) }
|
||||
return null
|
||||
}
|
||||
|
||||
// VERROU de sérialisation : frappe_pg ne supporte pas la concurrence. Le tick récurrent ET les runs
|
||||
// manuels (preview/run) passent tous par `sync()` → on les met en FILE pour qu'ils ne se chevauchent
|
||||
// JAMAIS (sinon « socket hang up » + écritures perdues dans un rollback). Chaque appel attend le précédent.
|
||||
|
|
@ -544,7 +649,7 @@ function sync (opts = {}) {
|
|||
// INGESTION des tickets ASSIGNÉS À UN TECH (≠ pool 3301) → Dispatch Job assignés + datés, ÉDITABLES/réordonnables dans Ops.
|
||||
// Idempotent par legacy_ticket_id (réutilise buildJob → coords/client/adresse/sujet+#/scheduled_date/legacy_dept-couleur).
|
||||
// Terrain seulement + fenêtre due_date [loDays, hiDays] (défaut -7j→+30j) pour borner le volume. dryRun = APERÇU (0 écriture).
|
||||
async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, includeClosed = false } = {}) {
|
||||
async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, includeClosed = false, allDates = false } = {}) {
|
||||
resetCaches()
|
||||
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible sur le hub' }
|
||||
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
|
||||
|
|
@ -553,21 +658,27 @@ async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, i
|
|||
if (!ids.length) return { ok: true, dryRun, created: 0, note: 'aucun tech mappé' }
|
||||
const now = Math.floor(Date.now() / 1000), D = 86400
|
||||
const lo = now + loDays * D, hi = now + hiDays * D
|
||||
// Compte/adresse EFFECTIFS via le ticket PARENT (cf. fetchTargoTickets) : un enfant sans account_id hérite du parent
|
||||
// → se rattache au bon client + apparaît dans la fiche. Même jointure `pt` + COALESCE(propre, parent).
|
||||
const [rows] = await p.query(
|
||||
`SELECT t.id, t.assign_to, t.status AS tk_status, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
|
||||
`SELECT t.id, t.assign_to, t.status AS tk_status, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id,
|
||||
COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) AS account_id,
|
||||
COALESCE(NULLIF(t.delivery_id, 0), NULLIF(pt.delivery_id, 0)) AS delivery_id,
|
||||
t.parent, t.waiting_for, t.participant, t.followed_by, t.important,
|
||||
t.date_create, t.last_update,
|
||||
a.first_name, a.last_name, a.company, a.email, a.cell, a.tel_home, a.address1, a.address2, a.city, a.state, a.zip,
|
||||
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.placemarks_id AS dv_pmid, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
|
||||
(SELECT mm.msg FROM ticket_msg mm WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%' ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
|
||||
(SELECT mm3.msg FROM ticket_msg mm3 WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
|
||||
FROM ticket t
|
||||
LEFT JOIN ticket pt ON pt.id = t.parent
|
||||
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
|
||||
LEFT JOIN account a ON a.id = t.account_id
|
||||
LEFT JOIN delivery dv ON dv.id = COALESCE(NULLIF(t.delivery_id, 0),
|
||||
(SELECT d2.id FROM delivery d2 WHERE t.account_id > 0 AND d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||||
(SELECT d3.id FROM delivery d3 WHERE t.account_id > 0 AND d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1))
|
||||
WHERE ${includeClosed ? '' : "t.status = 'open' AND "}t.assign_to IN (?) AND t.due_date BETWEEN ? AND ?
|
||||
ORDER BY t.due_date ASC`, [ids, lo, hi])
|
||||
LEFT JOIN account a ON a.id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0))
|
||||
LEFT JOIN delivery dv ON dv.id = COALESCE(NULLIF(t.delivery_id, 0), NULLIF(pt.delivery_id, 0),
|
||||
(SELECT d2.id FROM delivery d2 WHERE COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) > 0 AND d2.account_id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
|
||||
(SELECT d3.id FROM delivery d3 WHERE COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) > 0 AND d3.account_id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) ORDER BY d3.id DESC LIMIT 1))
|
||||
WHERE ${includeClosed ? '' : "t.status = 'open' AND "}t.assign_to IN (?)${allDates ? '' : ' AND t.due_date BETWEEN ? AND ?'}
|
||||
ORDER BY t.due_date ASC`, allDates ? [ids] : [ids, lo, hi])
|
||||
// Terrain : élargi pour rattraper les vraies interventions (coupure/bris/modem/fibre/ONU/décodeur/ramassage/…),
|
||||
// pas seulement install/réparation. Testé sur sujet+département. NEG_RE exclut congés/absences/réunions/formations.
|
||||
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall|coupure|bris|signal|modem|\bonu\b|\bont\b|fibre|ramassage|connect|d[eé]viation|antenne|prise|internet|d[eé]m[eé]nagement|changement|activation|\bstb\b|d[eé]codeur/i
|
||||
|
|
@ -582,10 +693,14 @@ async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, i
|
|||
// (ingestion normale + récup. historique) → rattrape « Coupure d'internet St-Stanislas » sans ingérer l'admin.
|
||||
if (!(/est\.\s?time/i.test(subj) || FIELD_RE.test(subj + ' ¦ ' + (t.dept || '')))) continue
|
||||
const tech = staffToTech[t.assign_to]; if (!tech) continue
|
||||
const b = await buildJob(t)
|
||||
// Court-circuit : déjà importé + assigné + géolocalisé → RIEN à faire. Évite le géocodage COÛTEUX de buildJob à CHAQUE
|
||||
// passage sur ~1000 tickets (le gros du temps). buildJob n'est appelé que pour un ticket neuf ou incomplet.
|
||||
const hasCo = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 1e-4
|
||||
const ex = await findExisting(String(t.id))
|
||||
if (ex && ex.assigned_tech && hasCo(ex.latitude) && hasCo(ex.longitude)) { skipped++; if (dryRun) details.push({ legacy_id: String(t.id), action: 'exists-complete' }); continue }
|
||||
const b = await buildJob(t, { dryRun })
|
||||
const isClosed = String(t.tk_status) === 'closed'
|
||||
b.payload.assigned_tech = tech; b.payload.status = isClosed ? 'Completed' : 'assigned' // ticket F fermé → job déjà fait
|
||||
const ex = await findExisting(b.legacy_id)
|
||||
if (ex) {
|
||||
const patch = {}
|
||||
if (!ex.assigned_tech) { patch.assigned_tech = tech; patch.status = 'assigned' } // ne CLOBBE jamais un tech déjà posé par le répartiteur
|
||||
|
|
@ -602,9 +717,11 @@ async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, i
|
|||
const r = await erp.create('Dispatch Job', b.payload)
|
||||
if (r && r.ok) created++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) }
|
||||
}
|
||||
// Relie l'Issue sous-jacente au client (les DJ « complets » court-circuités plus haut sont couverts par backfillIssueCustomers).
|
||||
if (b.payload.customer) await ensureIssueCustomer(t, b.payload.customer, { dryRun })
|
||||
} catch (e) { errors++; errSamples.push({ legacy_id: String(t.id), error: String((e && e.message) || e) }) }
|
||||
}
|
||||
return { ok: true, dryRun, window_days: [loDays, hiDays], candidates: (rows || []).length, created, updated, skipped, errors, error_samples: errSamples.slice(0, 6), sample: details.slice(0, 25) }
|
||||
return { ok: true, dryRun, window_days: [loDays, hiDays], candidates: (rows || []).length, created, updated, skipped, errors, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, error_samples: errSamples.slice(0, 6), sample: details.slice(0, 25) }
|
||||
}
|
||||
function ingestAssigned (opts = {}) { const run = _syncLock.then(() => ingestAssignedImpl(opts), () => ingestAssignedImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } // même verrou séquentiel que sync (frappe_pg)
|
||||
|
||||
|
|
@ -618,7 +735,7 @@ async function syncImpl ({ dryRun = false } = {}) {
|
|||
const details = []
|
||||
for (const t of tickets) {
|
||||
try {
|
||||
const b = await buildJob(t)
|
||||
const b = await buildJob(t, { dryRun })
|
||||
if (!b.matched.customer) unmatched++
|
||||
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1
|
||||
if (!b.matched.coords) noCoords++ // ni delivery ni Service Location ni RQA → routage indisponible (à diagnostiquer)
|
||||
|
|
@ -652,19 +769,21 @@ async function syncImpl ({ dryRun = false } = {}) {
|
|||
else { errors++; const msg = (r && r.error) || 'update failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'update', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'update-failed', job: ex.name, error: msg }) }
|
||||
} else skipped++
|
||||
} else if (dryRun) {
|
||||
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, coords: b.matched.coords, coord_src: b.matched.coord_src, delivery_id: b.matched.delivery_id, addr: b.addr })
|
||||
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, coords: b.matched.coords, coord_src: b.matched.coord_src, delivery_id: b.matched.delivery_id, parent: b.matched.parent, addr: b.addr })
|
||||
} else {
|
||||
const r = await erp.create('Dispatch Job', b.payload)
|
||||
if (r && r.ok) { created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: r.name, subject: b.payload.subject, customer_matched: b.matched.customer }) }
|
||||
else { errors++; const msg = (r && r.error) || 'create failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'create', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'create-failed', error: msg }) }
|
||||
}
|
||||
// Relie l'Issue sous-jacente au client (indépendant du DJ : couvre aussi le cas « DJ déjà là mais Issue.customer vide »).
|
||||
if (b.payload.customer) await ensureIssueCustomer(t, b.payload.customer, { dryRun })
|
||||
} catch (e) {
|
||||
errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) })
|
||||
}
|
||||
}
|
||||
let closedResolved = 0
|
||||
if (!dryRun) { try { const cr = await closeResolved(); closedResolved = cr.closed } catch (e) { log('closeResolved error:', e.message) } } // retire les DJ dont le ticket legacy est fermé
|
||||
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
|
||||
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
|
||||
if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat
|
||||
return { ...summary, details }
|
||||
}
|
||||
|
|
@ -1168,7 +1287,23 @@ function startSync () {
|
|||
// Garde anti-chevauchement : si un import dépasse l'intervalle (beaucoup de tickets), NE PAS relancer par-dessus
|
||||
// (sinon doublons Dispatch Job + contention ERPNext). On saute le tick tant que le précédent tourne.
|
||||
let _syncInFlight = false
|
||||
const tick = () => { if (_syncInFlight) { log('legacy-dispatch-sync: passage précédent encore en cours → tick sauté'); return } _syncInFlight = true; sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message)).finally(() => { _syncInFlight = false }) }
|
||||
// Import AUTO à chaque tick : (1) le pool 3301 (sync) PUIS (2) les tickets assignés à un vrai tech (ingestAssigned,
|
||||
// allDates → tous les tickets terrain OUVERTS assignés, pas seulement la fenêtre due_date ±jours). Chaînés (même _syncLock,
|
||||
// pas de chevauchement). Désactivable via LEGACY_DISPATCH_ASSIGNED_AUTO=off (garde le pool seul).
|
||||
const assignedAuto = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_ASSIGNED_AUTO || ''))
|
||||
// ingestAssigned re-scanne ~1000 tickets → on ne le lance qu'1 tick sur N (défaut 4 ≈ horaire à 15 min), pas à chaque tick.
|
||||
// (Le court-circuit « déjà importé » rend les passages en régime permanent rapides, mais on borne quand même la cadence.)
|
||||
const assignedEvery = Math.max(1, Number(process.env.LEGACY_DISPATCH_ASSIGNED_EVERY) || 4)
|
||||
let _tickN = 0
|
||||
const tick = () => {
|
||||
if (_syncInFlight) { log('legacy-dispatch-sync: passage précédent encore en cours → tick sauté'); return }
|
||||
_syncInFlight = true
|
||||
const runAssigned = assignedAuto && (_tickN % assignedEvery === 0); _tickN++
|
||||
sync({ dryRun: false })
|
||||
.then(() => runAssigned ? ingestAssigned({ dryRun: false, allDates: true }) : null)
|
||||
.catch(e => log('legacy-dispatch-sync tick error:', e.message))
|
||||
.finally(() => { _syncInFlight = false })
|
||||
}
|
||||
// 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
|
||||
setTimeout(tick, 90 * 1000)
|
||||
_timer = setInterval(tick, minutes * 60 * 1000)
|
||||
|
|
@ -1675,15 +1810,25 @@ async function handle (req, res, method, path) {
|
|||
if (!id) return json(res, 400, { ok: false, error: 'id requis' })
|
||||
return json(res, 200, await ticketLookup(id))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'GET') { // APERÇU (0 écriture). ?closed=1 inclut les tickets fermés (récup. historique) ; ?lo=&hi= fenêtre due_date en jours.
|
||||
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'GET') { // APERÇU (0 écriture). ?closed=1 inclut les fermés (récup.) ; ?all=1 = tous les tickets ouverts assignés (ignore la fenêtre) ; sinon ?lo=&hi= fenêtre due_date en jours.
|
||||
const q = new URL(req.url, 'http://localhost').searchParams
|
||||
return json(res, 200, await ingestAssigned({ dryRun: true, loDays: Number(q.get('lo')) || -7, hiDays: Number(q.get('hi')) || 30, includeClosed: q.get('closed') === '1' }))
|
||||
return json(res, 200, await ingestAssigned({ dryRun: true, loDays: Number(q.get('lo')) || -7, hiDays: Number(q.get('hi')) || 30, includeClosed: q.get('closed') === '1', allDates: q.get('all') === '1' }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'POST') { // INGÈRE : crée les Dispatch Job assignés (terrain, fenêtre). ?closed=1 = récupère aussi les jobs fermés (Completed).
|
||||
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'POST') { // INGÈRE : crée les Dispatch Job assignés (terrain). ?closed=1 = fermés aussi (Completed) ; ?all=1 = tous les ouverts assignés (ignore la fenêtre due_date).
|
||||
const q = new URL(req.url, 'http://localhost').searchParams
|
||||
return json(res, 200, await ingestAssigned({ dryRun: false, loDays: Number(q.get('lo')) || -7, hiDays: Number(q.get('hi')) || 30, includeClosed: q.get('closed') === '1' }))
|
||||
return json(res, 200, await ingestAssigned({ dryRun: false, loDays: Number(q.get('lo')) || -7, hiDays: Number(q.get('hi')) || 30, includeClosed: q.get('closed') === '1', allDates: q.get('all') === '1' }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
|
||||
if (path === '/dispatch/legacy-sync/backfill-sl' && (method === 'GET' || method === 'POST')) { // backfill one-time : Service Location manquantes des Customers legacy. GET = dry-run (0 écriture) · POST = applique. ?limit= plafonne.
|
||||
const u = new URL(req.url, 'http://localhost')
|
||||
const limit = Math.max(0, Number(u.searchParams.get('limit')) || 0)
|
||||
return json(res, 200, await backfillServiceLocations({ dryRun: method === 'GET', limit }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/backfill-issues' && (method === 'GET' || method === 'POST')) { // backfill one-time : Issue.customer manquant sur les tickets legacy déjà importés (→ ils remontent dans la fiche client). GET = dry-run (0 écriture) · POST = applique. ?limit= plafonne.
|
||||
const u = new URL(req.url, 'http://localhost')
|
||||
const limit = Math.max(0, Number(u.searchParams.get('limit')) || 0)
|
||||
return json(res, 200, await backfillIssueCustomers({ dryRun: method === 'GET', limit }))
|
||||
}
|
||||
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'GET') return json(res, 200, await reimportAddresses({ dryRun: true })) // aperçu (0 écriture)
|
||||
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'POST') return json(res, 200, await reimportAddresses({ dryRun: false })) // applique
|
||||
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'GET') return json(res, 200, await fillMissingCoords({ dryRun: true })) // aperçu géocodage des « hors carte »
|
||||
|
|
@ -1859,4 +2004,147 @@ async function reconcileLegacyJobs (opts = {}) {
|
|||
return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
|
||||
}
|
||||
|
||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
||||
// ─── BACKFILL ONE-TIME : Service Location manquantes des Customers legacy ───────────────────────────
|
||||
// Contexte : pendant la fenêtre de déploiement du 2026-07-08, la création de Service Location échouait
|
||||
// (bug `address_validation_status:'review'` rejeté par ERPNext ; corrigé → 'pending'). De plus, le
|
||||
// court-circuit d'`ingestAssignedImpl` (findExisting AVANT buildJob) fait que le pont NE re-tentera PLUS
|
||||
// la SL des customers dont le Dispatch Job existe déjà (tech + coords). Cette passe DÉDIÉE, indépendante
|
||||
// du chemin chaud, crée la Service Location manquante pour chaque Customer legacy qui n'en a aucune.
|
||||
//
|
||||
// Idempotent : on ne cible QUE les customers SANS Service Location (l'ensemble des SL existantes est relu
|
||||
// à chaque exécution → re-run = 0 doublon). Adresse dérivée du legacy : delivery (adresse de SERVICE,
|
||||
// coords préférées) > adresse de facturation du compte. Batché/throttlé (ERPNext = socket hang up sous rafale).
|
||||
// Dry-run par défaut (0 écriture) → renvoie les compteurs ; POST/force pour appliquer.
|
||||
async function backfillServiceLocations ({ dryRun = true, limit = 0, throttleMs = 150 } = {}) {
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
|
||||
|
||||
// 1) Tous les Customers legacy (legacy_account_id renseigné), paginés. listRaw → on distingue « page vide » d'une ERREUR.
|
||||
const customers = []
|
||||
for (let start = 0; ; start += 500) {
|
||||
const r = await erp.listRaw('Customer', { filters: [['legacy_account_id', '>', 0]], fields: ['name', 'customer_name', 'legacy_account_id'], limit: 500, start })
|
||||
if (!r.ok) return { ok: false, error: 'Customer list: ' + r.error, at_start: start }
|
||||
customers.push(...r.rows)
|
||||
if (r.rows.length < 500) break
|
||||
}
|
||||
|
||||
// 2) Ensemble des Customers qui possèdent DÉJÀ ≥1 Service Location (idempotence). Paginé intégralement.
|
||||
const withSL = new Set()
|
||||
for (let start = 0; ; start += 500) {
|
||||
const r = await erp.listRaw('Service Location', { filters: [['customer', 'is', 'set']], fields: ['name', 'customer'], limit: 500, start })
|
||||
if (!r.ok) return { ok: false, error: 'Service Location list: ' + r.error, at_start: start }
|
||||
for (const s of r.rows) if (s.customer) withSL.add(s.customer)
|
||||
if (r.rows.length < 500) break
|
||||
}
|
||||
|
||||
// 3) Candidats = Customers legacy SANS aucune Service Location.
|
||||
let candidates = customers.filter(c => !withSL.has(c.name) && Number(c.legacy_account_id) > 0)
|
||||
const legacyCustomers = customers.length
|
||||
const missingSL = candidates.length
|
||||
if (limit > 0) candidates = candidates.slice(0, limit)
|
||||
|
||||
// 4) Résout l'adresse legacy des comptes candidats (delivery préférée, sinon facturation) par lots.
|
||||
const acctIds = [...new Set(candidates.map(c => Number(c.legacy_account_id)).filter(Boolean))]
|
||||
const deliv = {} // account_id -> meilleur delivery {id,address1,city,zip,lat,lon}
|
||||
const bill = {} // account_id -> facturation {address1,city,zip}
|
||||
for (let i = 0; i < acctIds.length; i += 500) {
|
||||
const chunk = acctIds.slice(i, i + 500)
|
||||
// Meilleur delivery par compte = coords valides d'abord (comme la jointure COALESCE de fetchTargoTickets), puis le plus récent.
|
||||
const [drows] = await p.query(
|
||||
'SELECT id, account_id, address1, city, zip, latitude AS lat, longitude AS lon FROM delivery WHERE account_id IN (?) ORDER BY account_id, (latitude IS NOT NULL AND ABS(latitude) > 1) DESC, id DESC',
|
||||
[chunk])
|
||||
for (const d of drows) if (!(d.account_id in deliv)) deliv[d.account_id] = d // 1re ligne par compte = la meilleure
|
||||
const [arows] = await p.query('SELECT id, address1, city, zip FROM account WHERE id IN (?)', [chunk])
|
||||
for (const a of arows) bill[a.id] = a
|
||||
}
|
||||
|
||||
// 5) Plan : pour chaque candidat, choisir delivery (service) > facturation. Sans adresse exploitable → non traitable.
|
||||
const plan = []; const noAddr = []
|
||||
for (const c of candidates) {
|
||||
const acct = Number(c.legacy_account_id)
|
||||
const d = deliv[acct]; const b = bill[acct]
|
||||
let rec = null
|
||||
if (d && String(d.address1 || '').trim()) rec = { src: 'delivery', line: d.address1, city: d.city, zip: d.zip, lat: d.lat, lon: d.lon, deliveryId: d.id }
|
||||
else if (b && String(b.address1 || '').trim()) rec = { src: 'billing', line: b.address1, city: b.city, zip: b.zip, lat: null, lon: null, deliveryId: null }
|
||||
if (!rec) { noAddr.push({ customer: c.name, account_id: acct }); continue }
|
||||
plan.push({ customer: c.name, customer_name: c.customer_name, account_id: acct, ...rec, has_coords: !!coord(rec.lat, rec.lon) })
|
||||
}
|
||||
|
||||
const srcTally = plan.reduce((m, x) => { m[x.src] = (m[x.src] || 0) + 1; return m }, {})
|
||||
const summary = { ok: true, dry_run: dryRun, legacy_customers: legacyCustomers, missing_sl: missingSL, scanned: candidates.length, creatable: plan.length, no_address: noAddr.length, by_source: srcTally, with_coords: plan.filter(x => x.has_coords).length }
|
||||
|
||||
if (dryRun) return { ...summary, samples: plan.slice(0, 15), no_address_samples: noAddr.slice(0, 15) }
|
||||
|
||||
// 6) Applique : createServiceLocation (helper canonique — address_validation_status:'pending', coords via coord(),
|
||||
// legacy_delivery_id) en SÉQUENTIEL + throttle (ERPNext plante sous rafale). force:true = hors kill-switch.
|
||||
let created = 0; let failed = 0; const errors = []
|
||||
for (const x of plan) {
|
||||
const sl = await createServiceLocation(x.customer, { line: x.line, city: x.city, zip: x.zip, lat: x.lat, lon: x.lon, deliveryId: x.deliveryId }, false, { force: true })
|
||||
if (sl && sl.name) created++
|
||||
else { failed++; if (errors.length < 50) errors.push({ customer: x.customer, account_id: x.account_id, src: x.src }) }
|
||||
if (throttleMs) await sleep(throttleMs)
|
||||
if (created % 25 === 0 && created) await sleep(400) // pause plus longue tous les 25 pour laisser respirer les workers Frappe
|
||||
}
|
||||
return { ...summary, created, failed, error_samples: errors }
|
||||
}
|
||||
|
||||
// ─── BACKFILL ONE-TIME : Issue.customer manquant sur les tickets legacy DÉJÀ importés ──────────────
|
||||
// Contexte : le chemin chaud (sync/ingestAssigned) ne relie l'Issue au client que lorsqu'il (re)traite un
|
||||
// ticket ; mais le court-circuit `findExisting` d'ingestAssignedImpl saute les Dispatch Job « complets »
|
||||
// (tech + coords) → leur Issue sous-jacente garde `customer` vide (Customer créé après la migration). Cette
|
||||
// passe DÉDIÉE parcourt TOUS les Dispatch Job legacy portant un `customer` et assure Issue.customer :
|
||||
// update si l'Issue existe (vide/différent), create minimale si elle manque (cf. ensureIssueCustomer).
|
||||
// Idempotent (re-run = 0 écriture sur les déjà-liées). Dry-run par défaut (0 écriture) → renvoie les compteurs.
|
||||
function backfillIssueCustomers (opts = {}) { const run = _syncLock.then(() => backfillIssueCustomersImpl(opts), () => backfillIssueCustomersImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } // même verrou séquentiel (frappe_pg)
|
||||
async function backfillIssueCustomersImpl ({ dryRun = true, limit = 0, throttleMs = 60 } = {}) {
|
||||
if (!LINK_ISSUES) return { ok: false, error: 'LINK_ISSUES désactivé (LEGACY_DISPATCH_LINK_ISSUES=off)' }
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
|
||||
// 1) Tous les Dispatch Job legacy portant un customer, paginés. listRaw → distingue « page vide » d'une ERREUR.
|
||||
const jobs = []
|
||||
for (let start = 0; ; start += 500) {
|
||||
const r = await erp.listRaw('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['customer', 'is', 'set']], fields: ['name', 'legacy_ticket_id', 'customer', 'subject'], limit: 500, start })
|
||||
if (!r.ok) return { ok: false, error: 'Dispatch Job list: ' + r.error, at_start: start }
|
||||
jobs.push(...r.rows)
|
||||
if (r.rows.length < 500) break
|
||||
if (limit > 0 && jobs.length >= limit) break
|
||||
}
|
||||
const rows = limit > 0 ? jobs.slice(0, limit) : jobs
|
||||
const totalJobs = rows.length
|
||||
|
||||
// 2) État des Issue par legacy_ticket_id (batché). On distingue Issue absente vs Issue à customer vide/différent.
|
||||
const ids = [...new Set(rows.map(j => Number(j.legacy_ticket_id)).filter(Number.isFinite))]
|
||||
const issByTk = new Map()
|
||||
for (let i = 0; i < ids.length; i += 200) {
|
||||
const chunk = ids.slice(i, i + 200)
|
||||
const iss = await erp.list('Issue', { filters: [['legacy_ticket_id', 'in', chunk]], fields: ['name', 'legacy_ticket_id', 'customer'], limit: chunk.length + 10 })
|
||||
for (const r of (iss || [])) issByTk.set(Number(r.legacy_ticket_id), r)
|
||||
}
|
||||
|
||||
let alreadyLinked = 0, linked = 0, createdIssues = 0, missingIssue = 0, errors = 0
|
||||
const samples = [], errSamples = []
|
||||
for (const j of rows) {
|
||||
const tk = Number(j.legacy_ticket_id); if (!Number.isFinite(tk)) continue
|
||||
const iss = issByTk.get(tk)
|
||||
try {
|
||||
if (iss) {
|
||||
if (iss.customer === j.customer) { alreadyLinked++; continue }
|
||||
if (dryRun) { linked++; if (samples.length < 25) samples.push({ ticket: tk, issue: iss.name, from: iss.customer || '(vide)', to: j.customer, action: 'link' }); continue }
|
||||
const r = await erp.update('Issue', iss.name, { customer: j.customer })
|
||||
if (r && r.ok) { linked++; if (samples.length < 25) samples.push({ ticket: tk, issue: iss.name, to: j.customer, action: 'linked' }) }
|
||||
else { errors++; if (errSamples.length < 20) errSamples.push({ ticket: tk, issue: iss.name, error: (r && r.error) || 'update' }) }
|
||||
} else {
|
||||
missingIssue++
|
||||
if (dryRun) { createdIssues++; if (samples.length < 25) samples.push({ ticket: tk, customer: j.customer, action: 'create-issue' }); continue }
|
||||
const doc = { subject: String(j.subject || ('Ticket #' + tk)).slice(0, 255), status: 'Open', customer: j.customer, company: ISSUE_COMPANY, legacy_ticket_id: tk }
|
||||
const r = await erp.create('Issue', doc)
|
||||
if (r && r.ok && r.name) { createdIssues++; if (samples.length < 25) samples.push({ ticket: tk, issue: r.name, customer: j.customer, action: 'created' }) }
|
||||
else { errors++; if (errSamples.length < 20) errSamples.push({ ticket: tk, error: (r && r.error) || 'create' }) }
|
||||
}
|
||||
} catch (e) { errors++; if (errSamples.length < 20) errSamples.push({ ticket: tk, error: String((e && e.message) || e) }) }
|
||||
if (!dryRun && throttleMs) await sleep(throttleMs)
|
||||
}
|
||||
return { ok: true, dryRun, jobs: totalJobs, already_linked: alreadyLinked, linked, created_issues: createdIssues, missing_issue: missingIssue, errors, sample: samples, error_samples: errSamples }
|
||||
}
|
||||
|
||||
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
|
||||
|
|
|
|||
|
|
@ -492,6 +492,49 @@ async function handle (req, res, method, path, url) {
|
|||
return json(res, 200, { ok: true, name: created.name, ...summary })
|
||||
}
|
||||
|
||||
// POST /payments/record-on-account — paiement GÉNÉRAL du client (avance / non alloué), SANS facture.
|
||||
// Remplace l'ancien lien desk « Payment Entry/new ». preview:true → aucune écriture. Idempotent via reference_no.
|
||||
if (path === '/payments/record-on-account' && method === 'POST') {
|
||||
const body = await parseBody(req)
|
||||
const customer = body.customer
|
||||
const amount = Number(body.amount)
|
||||
if (!customer) return json(res, 400, { error: 'customer requis' })
|
||||
if (!(amount > 0)) return json(res, 400, { error: 'Montant invalide.' })
|
||||
const company = body.company || 'TARGO'
|
||||
// Compte clients (paid_from) = receivable par défaut de la SOCIÉTÉ. On NE DEVINE PAS : erreur si absent.
|
||||
let co
|
||||
try { co = await erp.get('Company', company) } catch (e) { return json(res, 502, { error: 'Société introuvable : ' + company }) }
|
||||
const paidFrom = co && co.default_receivable_account
|
||||
if (!paidFrom) return json(res, 502, { error: 'Compte clients par défaut introuvable sur la société — à configurer dans ERPNext.' })
|
||||
// Compte de dépôt (paid_to) : compte du Mode de paiement, sinon banque par défaut (même logique que record-invoice).
|
||||
const mode = body.mode_of_payment || 'Cheque'
|
||||
let paidTo
|
||||
try { const mop = await erp.get('Mode of Payment', mode); const acc = ((mop && mop.accounts) || []).find(a => a.company === company); paidTo = acc && acc.default_account } catch (e) { /* repli ci-dessous */ }
|
||||
if (!paidTo) paidTo = 'Banque - T'
|
||||
const refDate = body.reference_date || new Date().toISOString().slice(0, 10)
|
||||
const curr = co.default_currency || 'CAD'
|
||||
const summary = { customer, paid_amount: amount, on_account: true, company, paid_from: paidFrom, paid_to: paidTo, mode_of_payment: mode, reference_no: body.reference_no || null, reference_date: refDate }
|
||||
if (body.preview) return json(res, 200, { preview: true, ...summary })
|
||||
if (await paymentEntryExists(body.reference_no)) return json(res, 409, { error: 'Un paiement avec ce numéro de référence existe déjà.' })
|
||||
const clean = {
|
||||
doctype: 'Payment Entry', payment_type: 'Receive', company,
|
||||
party_type: 'Customer', party: customer,
|
||||
paid_amount: amount, received_amount: amount, source_exchange_rate: 1, target_exchange_rate: 1,
|
||||
paid_from: paidFrom, paid_to: paidTo,
|
||||
paid_from_account_currency: curr, paid_to_account_currency: curr,
|
||||
mode_of_payment: mode, reference_no: body.reference_no || undefined, reference_date: refDate, posting_date: refDate,
|
||||
remarks: `Paiement à l'avance / non alloué enregistré depuis la fiche client (${customer}).`,
|
||||
// PAS de references[] → ERPNext le laisse NON ALLOUÉ (crédit sur le compte client).
|
||||
}
|
||||
const created = await erp.create('Payment Entry', clean)
|
||||
if (!created.ok || !created.name) return json(res, 502, { error: 'Création du paiement échouée : ' + (created.error || 'inconnu') })
|
||||
const full = await erp.get('Payment Entry', created.name)
|
||||
const sub = await erp.raw('/api/method/frappe.client.submit', { method: 'POST', body: JSON.stringify({ doc: full }) })
|
||||
if (sub && sub.status >= 400) return json(res, 502, { error: 'Soumission échouée : ' + (erp.errorMessage ? erp.errorMessage(sub) : sub.status), name: created.name })
|
||||
log(`Payment Entry ${created.name} (à l'avance ${amount}$) pour ${customer}`)
|
||||
return json(res, 200, { ok: true, name: created.name, ...summary })
|
||||
}
|
||||
|
||||
// GET /payments/invoice/:invoice — invoice payment info for portal
|
||||
const invMatch = path.match(/^\/payments\/invoice\/(.+)$/)
|
||||
if (invMatch && method === 'GET') {
|
||||
|
|
|
|||
|
|
@ -1658,6 +1658,36 @@ async function handle (req, res, method, path, url) {
|
|||
return json(res, 500, { ok: false, error: (r && r.error) || 'création échouée' })
|
||||
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
|
||||
}
|
||||
// Job « bouche-trou » (filler) : job GÉNÉRIQUE de priorité STANDARD assigné à un tech pour occuper sa journée
|
||||
// (formation, réunion, admin, entretien véhicule…) → il compte dans l'occupation ⇒ RETIRÉ du dispatch régulier
|
||||
// (contrairement à /reserve = « soft », les urgences le traversent). Optionnel : génère aussi un TICKET (Issue) lié.
|
||||
if (path === '/roster/filler-job' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
if (!b.tech || !b.date) return json(res, 400, { ok: false, error: 'tech + date requis' })
|
||||
try {
|
||||
const subject = String(b.subject || 'Tâche interne — indisponible au dispatch').slice(0, 140)
|
||||
// Ticket (Issue) best-effort : un échec de billet ne doit PAS empêcher la création du job (le job = le cœur).
|
||||
let issueName = ''
|
||||
if (b.create_ticket) {
|
||||
try {
|
||||
const prio = { low: 'Low', medium: 'Medium', high: 'High' }[String(b.priority || 'medium')] || 'Medium'
|
||||
const iss = await retryWrite(() => erp.create('Issue', { subject, status: 'Open', priority: prio }))
|
||||
issueName = (iss && (iss.name || (iss.data && iss.data.name))) || ''
|
||||
} catch (e) { log('filler: ticket create failed — ' + e.message) }
|
||||
}
|
||||
const ref = await require('./dispatch').nextJobRef()
|
||||
const payload = {
|
||||
ticket_id: ref, subject,
|
||||
job_type: b.job_type || 'Interne', priority: b.priority || 'medium', status: 'assigned',
|
||||
assigned_tech: b.tech, scheduled_date: b.date,
|
||||
start_time: b.start_time || '08:00', duration_h: Number(b.duration_h) || 8,
|
||||
source_issue: issueName, notes: b.notes || '',
|
||||
}
|
||||
const r = await retryWrite(() => erp.create('Dispatch Job', payload))
|
||||
if (r && r.ok !== false) { invalidatePool(); return json(res, 200, { ok: true, name: r.name || (r.data && r.data.name), ref, issue: issueName }) }
|
||||
return json(res, 500, { ok: false, error: (r && r.error) || 'création échouée' })
|
||||
} catch (e) { return json(res, 500, { ok: false, error: e.message }) }
|
||||
}
|
||||
if (path === '/roster/job-comment' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
const text = String(b.text || '').trim(); if (!text) return json(res, 400, { ok: false, error: 'texte requis' })
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ const { log } = require('./helpers')
|
|||
|
||||
// Vocabulaire CANONIQUE des compétences terrain (= sorties possibles de deptToSkill).
|
||||
// Une demande à distance / facturation / vente ne requiert AUCUNE compétence → [].
|
||||
const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin']
|
||||
const SKILL_VOCAB = ['installation', 'réparation', 'tv', 'telephone', 'épissure', 'monteur', 'netadmin', 'support']
|
||||
|
||||
function normSkill (s) { return String(s || '').trim().toLowerCase() }
|
||||
function stripAccents (s) { return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '') }
|
||||
|
|
@ -71,7 +71,7 @@ const DEPARTMENT_SKILLS = {
|
|||
Épissure: ['épissure'],
|
||||
Fusionneur: ['épissure'],
|
||||
'Net Admin': ['netadmin'],
|
||||
Support: [], // dépannage à distance → n'importe quel agent
|
||||
Support: ['support'], // ne lister que les agents ayant la compétence « support » (pas les techs sans compétence)
|
||||
Facturation: [],
|
||||
Commercial: [], // vente / bureau
|
||||
Vente: [],
|
||||
|
|
|
|||
324
services/targo-hub/lib/staff-agent.js
Normal file
324
services/targo-hub/lib/staff-agent.js
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
'use strict'
|
||||
/**
|
||||
* staff-agent.js — Copilote OPS en langage naturel (Gemini function-calling), surface STAFF.
|
||||
*
|
||||
* BUT (parité UI↔NL) : le langage naturel peut faire ce que fait l'interface visuelle, en exposant
|
||||
* les ACTIONS EXISTANTES comme « outils » function-calling. NL et UI partagent UNE SEULE couche
|
||||
* d'action (les routes hub / fonctions que l'UI appelle déjà) → couverture(NL) = couverture(UI).
|
||||
*
|
||||
* RÉUTILISE l'existant (cf feedback_reuse_shared_components) :
|
||||
* - registre d'outils = services/targo-hub/lib/agent-tools.json (audience 'staff'),
|
||||
* - runtime Gemini function-calling = lib/ai.js `chat({tools, wantMessage})` (même boucle que
|
||||
* lib/agent.js répondeur SMS et lib/roster-assistant.js copilote roster),
|
||||
* - couche d'action = les routes hub EXISTANTES (roster.js/dispatch.js/conversation.js), appelées
|
||||
* en LOOPBACK autorisé (jeton de service + identité x-authentik-email) — zéro logique réécrite.
|
||||
*
|
||||
* SÉCURITÉ (cf feedback_ppa_double_charge_incident — une action auto peut coûter cher) :
|
||||
* - outils LECTURE : s'exécutent librement pendant le PLAN (résolution d'entités, aucun effet).
|
||||
* - outils ÉCRITURE/conséquents : NE s'exécutent PAS via le modèle. Ils sont mis en attente
|
||||
* (staged) avec un APERÇU. L'exécution réelle passe par POST /staff-agent/run APRÈS confirmation
|
||||
* humaine explicite (le modèle propose ; l'humain dispose). Pas d'endpoint arbitraire : /run mappe
|
||||
* type → exécuteur côté serveur (liste blanche), re-valide les permissions (parité usePermissions
|
||||
* via auth.effectiveCapabilities) et agit AU NOM de l'utilisateur connecté (x-authentik-email).
|
||||
*
|
||||
* Routes :
|
||||
* POST /staff-agent { text } → { ok, reply, actions:[{id,type,title,preview,params,permission,needs_confirmation}], transcript }
|
||||
* POST /staff-agent/run { actions } → { ok, results:[{type,ok,message|error}] }
|
||||
* GET /staff-agent/tools → { tools } (inventaire — parité/complétude)
|
||||
*/
|
||||
const cfg = require('./config')
|
||||
const { log, json, parseBody } = require('./helpers')
|
||||
const { toolsForModel, toolsByAudience } = require('./agent')
|
||||
const erp = require('./erp')
|
||||
|
||||
// ── Registre : outils STAFF (audience 'staff') depuis le registre UNIQUE agent-tools.json ──
|
||||
const STAFF_TOOLS_RAW = toolsByAudience('staff')
|
||||
const STAFF_TOOLS = toolsForModel(STAFF_TOOLS_RAW) // envoyés au modèle : {type, function} seulement
|
||||
const META = {} // name → { mode, permission, title }
|
||||
for (const t of STAFF_TOOLS_RAW) META[t.function.name] = { mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name }
|
||||
|
||||
// require paresseux (évite les cycles + n'alourdit pas le boot) — mêmes modules que l'UI appelle.
|
||||
const roster = () => require('./roster')
|
||||
const dispatch = () => require('./dispatch')
|
||||
|
||||
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
|
||||
|
||||
// ── Loopback AUTORISÉ vers les routes hub EXISTANTES ─────────────────────────
|
||||
// Réutilise EXACTEMENT les handlers que le front appelle (couche d'action unique). On imite le proxy
|
||||
// nginx /ops/hub : Authorization = jeton de service + x-authentik-email = utilisateur connecté (→
|
||||
// attribution, suivi, et vérifs d'identité des handlers). Pas d'endpoint fourni par le client : seuls
|
||||
// les chemins codés dans EXECUTORS sont atteignables.
|
||||
const PORT = cfg.PORT || 3300
|
||||
async function hubCall (method, path, body, email) {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (process.env.HUB_SERVICE_TOKEN) headers.Authorization = 'Bearer ' + process.env.HUB_SERVICE_TOKEN
|
||||
if (email) headers['x-authentik-email'] = email
|
||||
const opt = { method, headers }
|
||||
if (body != null && method !== 'GET') opt.body = JSON.stringify(body)
|
||||
const r = await fetch(`http://127.0.0.1:${PORT}${path}`, opt)
|
||||
let data = null
|
||||
try { data = await r.json() } catch { /* corps non-JSON */ }
|
||||
return { status: r.status, ok: r.ok, data: data || {} }
|
||||
}
|
||||
// Un résultat hub → { ok, message } normalisé (les routes renvoient {ok:false,error} ou {error}).
|
||||
function hubResult (r, okMsg) {
|
||||
const ok = r.ok && r.data && r.data.ok !== false && !r.data.error
|
||||
return { ok, message: ok ? okMsg : ((r.data && r.data.error) || `échec (HTTP ${r.status})`), status: r.status, data: r.data }
|
||||
}
|
||||
|
||||
async function resolveTechName (techId) {
|
||||
try { const techs = await roster().fetchTechnicians(); const t = techs.find(x => x.id === techId || x.name === techId); return t ? t.name : techId }
|
||||
catch { return techId }
|
||||
}
|
||||
|
||||
// ── Outils LECTURE (exécutés pendant le PLAN — sans effet de bord) ───────────
|
||||
async function read_list_technicians ({ query, skill } = {}) {
|
||||
let techs
|
||||
try { techs = await roster().fetchTechnicians() } catch (e) { return { error: 'techniciens indisponibles : ' + e.message } }
|
||||
const q = String(query || '').trim().toLowerCase()
|
||||
const sk = String(skill || '').trim().toLowerCase()
|
||||
let out = techs || []
|
||||
if (q) out = out.filter(t => String(t.name || '').toLowerCase().includes(q) || String(t.id || '').toLowerCase().includes(q))
|
||||
if (sk) out = out.filter(t => (t.skills || []).some(s => String(s).toLowerCase().includes(sk)))
|
||||
return { count: out.length, technicians: out.slice(0, 40).map(t => ({ id: t.id, name: t.name, status: t.status, skills: t.skills || [] })) }
|
||||
}
|
||||
async function read_get_job ({ job } = {}) {
|
||||
if (!job) return { error: 'job requis' }
|
||||
try {
|
||||
const d = await erp.get('Dispatch Job', job, { fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'customer_name', 'service_location', 'job_type', 'booking_status'] })
|
||||
if (!d || !d.name) return { error: 'job introuvable : ' + job }
|
||||
return { job: d.name, subject: d.subject, status: d.status, assigned_tech: d.assigned_tech || null, scheduled_date: d.scheduled_date || null, start_time: d.start_time || null, duration_h: d.duration_h, priority: d.priority, customer_name: d.customer_name || '', service_location: d.service_location || '', job_type: d.job_type, booking_status: d.booking_status || '' }
|
||||
} catch (e) { return { error: e.message } }
|
||||
}
|
||||
async function read_find_slot ({ duration_h, skill, after_date } = {}) {
|
||||
try {
|
||||
const slots = await dispatch().suggestSlots({ duration_h: Number(duration_h) || 1, skill: skill || '', after_date: after_date || todayET(), limit: 5 })
|
||||
return { count: (slots || []).length, slots: (slots || []).slice(0, 5) }
|
||||
} catch (e) { return { error: e.message } }
|
||||
}
|
||||
const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot }
|
||||
|
||||
// ── Jours de la semaine — clés du weekly_schedule (= DOW_KEYS de roster.js) ──
|
||||
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||
const DAY_FR = { mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }
|
||||
const DAY_ALIASES = {
|
||||
mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun',
|
||||
monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun',
|
||||
lun: 'mon', mar: 'tue', mer: 'wed', jeu: 'thu', ven: 'fri', sam: 'sat', dim: 'sun',
|
||||
lundi: 'mon', mardi: 'tue', mercredi: 'wed', jeudi: 'thu', vendredi: 'fri', samedi: 'sat', dimanche: 'sun',
|
||||
}
|
||||
function normDays (days) {
|
||||
const set = new Set()
|
||||
for (const d of (Array.isArray(days) ? days : [])) { const k = DAY_ALIASES[String(d || '').trim().toLowerCase()]; if (k) set.add(k) }
|
||||
return DAY_KEYS.filter(k => set.has(k)) // ordre lun→dim, dédupliqué
|
||||
}
|
||||
function normTime (t, dflt = '') {
|
||||
const m = String(t || '').trim().match(/^(\d{1,2})(?:\s*[:hH]\s*(\d{2}))?/) // "8", "8:00", "8h", "16h00"
|
||||
if (!m) return dflt
|
||||
return String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0') + ':' + (m[2] || '00')
|
||||
}
|
||||
function buildWeeklySchedule (days, start, end) {
|
||||
const st = normTime(start), en = normTime(end)
|
||||
const sched = {}
|
||||
for (const k of normDays(days)) sched[k] = { start: st, end: en }
|
||||
return sched
|
||||
}
|
||||
|
||||
// ── Outils ÉCRITURE — { permission, build(params)→{title,preview,params[,severity]}, exec(params,email) } ──
|
||||
// build : mis en attente pendant le plan (aperçu). exec : exécution réelle après confirmation (via /run),
|
||||
// chaque exec = fine enveloppe sur une route/fonction hub EXISTANTE.
|
||||
const WRITES = {
|
||||
create_job: {
|
||||
permission: 'create_jobs',
|
||||
async build (p) {
|
||||
const params = { subject: String(p.subject || '').trim(), customer_id: p.customer_id || '', service_location: p.service_location || '', priority: p.priority || '', job_type: p.job_type || '', notes: p.notes || '', auto_assign: p.auto_assign !== false }
|
||||
const preview = `« ${params.subject || 'Intervention'} »${params.job_type ? ' · ' + params.job_type : ''}${params.priority ? ' · priorité ' + params.priority : ''}${params.customer_id ? ' · client ' + params.customer_id : ''} · ${params.auto_assign ? 'assignation auto' : 'non assigné'}`
|
||||
return { title: META.create_job.title, preview, params }
|
||||
},
|
||||
async exec (p) {
|
||||
const r = await dispatch().agentCreateDispatchJob({ customer_id: p.customer_id || '', service_location: p.service_location || '', subject: p.subject, priority: p.priority || undefined, job_type: p.job_type || undefined, notes: p.notes || '', auto_assign: p.auto_assign })
|
||||
return { ok: !!(r && r.success), message: (r && r.message) || 'job créé', job_id: r && r.job_id, assigned_tech: r && r.assigned_tech }
|
||||
},
|
||||
},
|
||||
assign_tech: {
|
||||
permission: 'assign_jobs',
|
||||
async build (p) {
|
||||
const name = await resolveTechName(p.tech_id)
|
||||
const params = { job: p.job, tech: p.tech_id, date: p.date || '', start: p.start || '' }
|
||||
return { title: META.assign_tech.title, preview: `${p.job} → ${name} (${p.tech_id})${p.date ? ' · ' + p.date : ''}${p.start ? ' à ' + p.start : ''}`, params }
|
||||
},
|
||||
async exec (p, email) {
|
||||
const body = { job: p.job, tech: p.tech }
|
||||
if (p.date) body.date = p.date
|
||||
if (p.start) body.start = p.start
|
||||
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} assigné à ${p.tech}`)
|
||||
},
|
||||
},
|
||||
add_assistant: {
|
||||
permission: 'assign_jobs',
|
||||
async build (p) {
|
||||
const name = p.tech_name || await resolveTechName(p.tech_id)
|
||||
return { title: META.add_assistant.title, preview: `Renfort sur ${p.job} : ${name} (${p.tech_id})`, params: { job: p.job, tech_id: p.tech_id, tech_name: name } }
|
||||
},
|
||||
async exec (p, email) {
|
||||
return hubResult(await hubCall('POST', '/roster/job/team', { job: p.job, add: { tech_id: p.tech_id, tech_name: p.tech_name || '' } }, email), `Assistant ${p.tech_id} ajouté à ${p.job}`)
|
||||
},
|
||||
},
|
||||
set_job_status: {
|
||||
permission: 'assign_jobs',
|
||||
async build (p) {
|
||||
return { title: META.set_job_status.title, preview: `${p.job} → statut « ${p.status} »`, params: { job: p.job, status: p.status }, severity: p.status === 'Cancelled' ? 'high' : 'normal' }
|
||||
},
|
||||
async exec (p, email) {
|
||||
return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { status: p.status } }, email), `${p.job} → ${p.status}`)
|
||||
},
|
||||
},
|
||||
reschedule_job: {
|
||||
permission: 'assign_jobs',
|
||||
async build (p) {
|
||||
return { title: META.reschedule_job.title, preview: `${p.job} reporté au ${p.date}${p.start ? ' à ' + p.start : ''}`, params: { job: p.job, date: p.date, start: p.start || '' } }
|
||||
},
|
||||
async exec (p, email) {
|
||||
// Conserve le tech assigné si possible (re-place l'heure via /roster/assign-job) ; sinon change la date via /roster/job/update.
|
||||
let tech = null
|
||||
try { const d = await erp.get('Dispatch Job', p.job, { fields: ['assigned_tech'] }); tech = d && d.assigned_tech } catch { /* fallback ci-dessous */ }
|
||||
if (tech) {
|
||||
const body = { job: p.job, tech, date: p.date }
|
||||
if (p.start) body.start = p.start
|
||||
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} reporté au ${p.date}`)
|
||||
}
|
||||
return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { scheduled_date: p.date } }, email), `${p.job} reporté au ${p.date}`)
|
||||
},
|
||||
},
|
||||
create_recurring_shift: {
|
||||
permission: 'assign_jobs',
|
||||
async build (p) {
|
||||
const days = normDays(p.days)
|
||||
const schedule = buildWeeklySchedule(p.days, p.start, p.end)
|
||||
const name = await resolveTechName(p.technicien_id)
|
||||
const st = normTime(p.start), en = normTime(p.end)
|
||||
const worked = days.map(k => DAY_FR[k]).join(', ') || '(aucun jour)'
|
||||
const off = DAY_KEYS.filter(k => !days.includes(k)).map(k => DAY_FR[k]).join(', ')
|
||||
const preview = `${name} (${p.technicien_id}) : ${st}–${en} le ${worked}${off ? ` · repos ${off}` : ''}`
|
||||
// schedule pré-calculé porté dans les params → /run le pousse tel quel sur la route weekly-schedule.
|
||||
return { title: META.create_recurring_shift.title, preview, params: { technicien_id: p.technicien_id, schedule } }
|
||||
},
|
||||
async exec (p, email) {
|
||||
if (!p.schedule || typeof p.schedule !== 'object' || !Object.keys(p.schedule).length) return { ok: false, message: 'horaire vide (aucun jour)' }
|
||||
return hubResult(await hubCall('POST', `/roster/technician/${encodeURIComponent(p.technicien_id)}/weekly-schedule`, { schedule: p.schedule }, email), 'Horaire récurrent enregistré (quarts matérialisés au prochain cycle)')
|
||||
},
|
||||
},
|
||||
follow_doc: {
|
||||
permission: null, // suivi = personnel/bas risque → tout staff authentifié
|
||||
async build (p) {
|
||||
const follow = p.follow !== false
|
||||
return { title: META.follow_doc.title, preview: `${follow ? 'Suivre' : 'Ne plus suivre'} ${p.doctype} ${p.name}`, params: { doctype: p.doctype, name: p.name, follow } }
|
||||
},
|
||||
async exec (p, email) {
|
||||
return hubResult(await hubCall('POST', '/conversations/follow', { doctype: p.doctype, name: p.name, follow: p.follow !== false }, email), (p.follow !== false) ? 'Suivi activé' : 'Suivi retiré')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── Runtime Gemini (function-calling) — même config/boucle que lib/agent.js & roster-assistant.js ──
|
||||
async function geminiChat (messages) {
|
||||
const msg = await require('./ai').chat({ task: 'nl', messages, tools: STAFF_TOOLS, maxTokens: 900, temperature: 0.1, wantMessage: true })
|
||||
return { choices: [{ message: msg }] }
|
||||
}
|
||||
|
||||
function systemPrompt (email) {
|
||||
return `Tu es le COPILOTE OPS (dispatch/planification) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec.
|
||||
Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` (${email})` : ''}.
|
||||
Tu transformes une commande en langage naturel en APPELS D'OUTILS.
|
||||
RÈGLES :
|
||||
- Utilise d'abord les outils de LECTURE (list_technicians, get_job, find_slot) pour RÉSOUDRE les entités. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
|
||||
- Les outils d'ÉCRITURE (create_job, assign_tech, add_assistant, set_job_status, reschedule_job, create_recurring_shift, follow_doc) NE S'EXÉCUTENT PAS tout de suite : ils sont PROPOSÉS puis CONFIRMÉS par l'utilisateur. Chaque appel te renvoie un APERÇU (staged=true). N'affirme JAMAIS qu'une action est faite ; annonce ce qui SERA fait après confirmation.
|
||||
- Horaire/quart RÉCURRENT : days parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions (« sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00. Il FAUT un technicien : si aucun n'est nommé, DEMANDE-le.
|
||||
- S'il manque une information essentielle (quel job ? quel tech ? quelle date ?), pose UNE question courte au lieu d'appeler un outil d'écriture.
|
||||
- Réponds en français, bref. Après avoir proposé des actions, résume-les en une phrase et invite à confirmer.`
|
||||
}
|
||||
|
||||
async function execToolPlan (name, args, ctx) {
|
||||
const meta = META[name]
|
||||
if (!meta) return { error: 'outil inconnu : ' + name }
|
||||
try {
|
||||
if (meta.mode === 'read') { const fn = READERS[name]; return fn ? await fn(args || {}) : { error: 'lecteur manquant : ' + name } }
|
||||
const w = WRITES[name]
|
||||
if (!w) return { error: 'exécuteur manquant : ' + name }
|
||||
const staged = await w.build(args || {}, ctx.email)
|
||||
const id = 'a' + (ctx.planned.length + 1)
|
||||
ctx.planned.push({ id, type: name, title: staged.title, preview: staged.preview, params: staged.params, permission: meta.permission || null, severity: staged.severity || 'normal', needs_confirmation: true })
|
||||
return { staged: true, requires_confirmation: true, title: staged.title, preview: staged.preview, note: "Action PROPOSÉE, NON exécutée. L'utilisateur doit la confirmer. Ne dis pas qu'elle est faite." }
|
||||
} catch (e) { return { error: String(e.message || e) } }
|
||||
}
|
||||
|
||||
// PLAN : boucle function-calling. Les LECTURES s'exécutent (résolution), les ÉCRITURES sont mises en attente.
|
||||
async function plan (text, email) {
|
||||
if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." }
|
||||
const planned = []
|
||||
const ctx = { email, planned }
|
||||
const messages = [{ role: 'system', content: systemPrompt(email) }, { role: 'user', content: String(text || '') }]
|
||||
let response
|
||||
try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
|
||||
const cur = [...messages]
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const msg = response.choices?.[0]?.message
|
||||
if (!msg) break
|
||||
const calls = msg.tool_calls // signal OpenAI-compat des appels d'outils (indépendant de finish_reason)
|
||||
if (!calls || !calls.length) break
|
||||
cur.push(msg)
|
||||
for (const tc of calls) {
|
||||
const args = (() => { try { return JSON.parse(tc.function.arguments || '{}') } catch { return {} } })()
|
||||
log(`[staff-agent] ${email || 'anon'} → ${tc.function.name}(${JSON.stringify(args)})`)
|
||||
const result = await execToolPlan(tc.function.name, args, ctx)
|
||||
cur.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, 6000) })
|
||||
}
|
||||
try { response = await geminiChat(cur) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
|
||||
}
|
||||
const reply = response.choices?.[0]?.message?.content || (planned.length ? 'Plan préparé — confirme pour exécuter.' : 'Aucune action détectée — reformule.')
|
||||
return { ok: true, reply, actions: planned, transcript: String(text || '') }
|
||||
}
|
||||
|
||||
// RUN : exécute les actions CONFIRMÉES. type → exécuteur (liste blanche) + re-vérif permission + identité.
|
||||
async function run (actions, email) {
|
||||
const list = Array.isArray(actions) ? actions.slice(0, 12) : []
|
||||
let caps = null
|
||||
try { caps = await require('./auth').effectiveCapabilities(email) } catch (e) { log('[staff-agent] perms indispo : ' + e.message) }
|
||||
const results = []
|
||||
for (const a of list) {
|
||||
const type = a && a.type
|
||||
const w = WRITES[type]
|
||||
if (!w) { results.push({ type, ok: false, error: 'action inconnue' }); continue }
|
||||
// Permissions (parité usePermissions) : appliquées si Authentik est branché (prod). En dev/aperçu
|
||||
// (pas de token → caps.configured=false) on n'enforce pas mais on journalise — le gate du hub
|
||||
// (jeton de service) garantit déjà que seul un membre du staff authentifié atteint /staff-agent.
|
||||
const need = w.permission
|
||||
if (need && caps && caps.configured) {
|
||||
const allowed = caps.is_superuser || (caps.capabilities && caps.capabilities[need] === true)
|
||||
if (!allowed) { results.push({ type, ok: false, denied: true, error: `permission requise : ${need}` }); continue }
|
||||
}
|
||||
try { results.push({ type, ...(await w.exec(a.params || {}, email)) }) }
|
||||
catch (e) { results.push({ type, ok: false, error: String(e.message || e) }) }
|
||||
}
|
||||
return { ok: results.length > 0 && results.every(r => r.ok), results }
|
||||
}
|
||||
|
||||
async function handle (req, res, method, path) {
|
||||
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
|
||||
if (path === '/staff-agent' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
try { return json(res, 200, await plan(b.text || '', email)) }
|
||||
catch (e) { return json(res, 200, { ok: false, error: 'Erreur copilote : ' + (e.message || e) }) }
|
||||
}
|
||||
if (path === '/staff-agent/run' && method === 'POST') {
|
||||
const b = await parseBody(req)
|
||||
try { return json(res, 200, await run(b.actions, email)) }
|
||||
catch (e) { return json(res, 200, { ok: false, error: 'Erreur exécution : ' + (e.message || e) }) }
|
||||
}
|
||||
if (path === '/staff-agent/tools' && method === 'GET') {
|
||||
return json(res, 200, { tools: STAFF_TOOLS_RAW.map(t => ({ name: t.function.name, mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name, description: t.function.description })) })
|
||||
}
|
||||
return json(res, 404, { error: 'staff-agent: route inconnue ' + path })
|
||||
}
|
||||
|
||||
module.exports = { handle, plan, run, buildWeeklySchedule, normDays, normTime, STAFF_TOOLS_RAW }
|
||||
|
|
@ -80,8 +80,11 @@ const CORE_STEPS = [
|
|||
{ key: 'soldes', label: 'Soldes des factures', fn: async () => { const r = await require('./legacy-payments').refreshOpenInvoices({ confirm: 'F-WINS' }); return `${r.updated || 0} soldé(s)` } },
|
||||
]
|
||||
// Tickets : SÉPARÉ (lourd ~70s : pull legacy + géocodage ; déjà sur scheduler 15 min + sync d'assignation live en Planification).
|
||||
const TICKET_STEP = { key: 'tickets', label: 'Tickets (dispatch)', fn: async () => { const r = await require('./legacy-dispatch-sync').sync({ dryRun: false }); const n = r && (r.created ?? r.ingested ?? r.jobs_created); return n != null ? `${n} ticket(s)` : 'à jour' } }
|
||||
function stepsFor (scope) { return scope === 'tickets' ? [TICKET_STEP] : scope === 'all' ? [...CORE_STEPS, TICKET_STEP] : CORE_STEPS }
|
||||
const TICKET_STEP = { key: 'tickets', label: 'Tickets (dispatch)', fn: async () => { const lds = require('./legacy-dispatch-sync'); const r = await lds.sync({ dryRun: false }); const n = (r && (r.created ?? r.ingested ?? r.jobs_created)) || 0; let a = null; try { a = await lds.ingestAssigned({ dryRun: false, allDates: true }) } catch (e) { /* pool créé quand même */ }; const na = (a && a.created) || 0; return `${n + na} ticket(s)${na ? ` (dont ${na} assigné(s))` : ''}` } }
|
||||
// Créer TOUS les comptes F manquants (pas seulement ceux référencés par une facture récente comme le step `clients`).
|
||||
// Lourd (scan complet des comptes F + création throttlée) → passe par le run ASYNC (polling), jamais une requête HTTP bloquante.
|
||||
const CREATE_ALL_STEP = { key: 'clients-all', label: 'Tous les comptes manquants (F→OPS)', fn: async () => { const r = await require('./legacy-sync').createCustomers({ confirm: 'F-WINS', limit: 100000 }); return `${r.created || 0} créé(s)${r.errors ? ` · ${r.errors} err` : ''}` } }
|
||||
function stepsFor (scope) { return scope === 'tickets' ? [TICKET_STEP] : scope === 'customers' ? [CREATE_ALL_STEP] : scope === 'all' ? [...CORE_STEPS, TICKET_STEP] : CORE_STEPS }
|
||||
|
||||
let _run = { running: false, scope: null, steps: [], started_at: null, finished_at: null }
|
||||
async function runManual ({ confirm, scope = 'core' }) {
|
||||
|
|
|
|||
|
|
@ -79,7 +79,9 @@ function geminiToMulaw (pcmB64) {
|
|||
|
||||
// ── Gemini Live tool definitions (different format than OpenAI) ──
|
||||
function buildGeminiTools () {
|
||||
const openaiTools = require('./agent-tools.json')
|
||||
// Outils CLIENT uniquement (audience filtrée + métadonnées retirées par lib/agent.js) — jamais les
|
||||
// outils d'écriture STAFF. execTool (lib/agent.js) ne dispatche de toute façon que les outils client.
|
||||
const openaiTools = require('./agent').TOOLS
|
||||
return [{
|
||||
functionDeclarations: openaiTools
|
||||
.filter(t => t.function.name !== 'get_chat_link') // voice doesn't need chat link
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ const server = http.createServer(async (req, res) => {
|
|||
'/conversations/email-ingest', // ingestion courriel n8n — PUBLIC mais protégé par X-Mail-Token dans le handler
|
||||
'/conversations/channel-ingest', // ingestion canal (web chat / 3CX / Facebook) — PUBLIC mais protégé par X-Mail-Token
|
||||
'/prefs', // préférences d'affichage par utilisateur : identité = x-authentik-email (forward-auth SSO), impersonation admin gatée dans le handler (groupes)
|
||||
'/avatars', // photos de profil : GET public (chargées par <img>), POST/DELETE keyés x-authentik-email (self), ?as= admin — gaté dans le handler
|
||||
'/chatwoot', // Dashboard App Chatwoot : /chatwoot/panel (iframe) PUBLIC ; /chatwoot/lookup protégé par CHATWOOT_PANEL_KEY (PII) dans le handler
|
||||
]
|
||||
// Toujours exiger le token (indépendant de HUB_GATE) pour toute route qui
|
||||
|
|
@ -121,7 +122,7 @@ const server = http.createServer(async (req, res) => {
|
|||
// gagnent quand même car `isPublic` court-circuite ce bloc en amont.
|
||||
const ALWAYS_ENFORCE = [
|
||||
'/devices', '/email-queue', '/campaigns', '/giftbit',
|
||||
'/auth', '/gmail', '/modem', '/olt', '/traccar', '/admin', '/collab',
|
||||
'/auth', '/gmail', '/modem', '/olt', '/traccar', '/admin', '/collab', '/staff-agent',
|
||||
'/network', '/sync', '/legacy-payments', '/telephony', '/flow',
|
||||
'/conversations', '/dispatch', '/sla', '/events', // /events/* = vue staff RSVP (PII : noms/courriels/décompte) → token requis
|
||||
'/payments/charge', '/payments/refund', '/payments/ppa-run', '/payments/send-link',
|
||||
|
|
@ -224,6 +225,7 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/auth/')) return auth.handle(req, res, method, path, url)
|
||||
if (path.startsWith('/conversations')) return conversation.handle(req, res, method, path, url)
|
||||
if (path === '/prefs') return require('./lib/user-prefs').handle(req, res, method, path) // préférences d'affichage par utilisateur (filtres/tri/densité)
|
||||
if (path === '/avatars' || path.startsWith('/avatars/')) return require('./lib/avatars').handle(req, res, method, path) // photos de profil par utilisateur
|
||||
if (path.startsWith('/sla/')) return require('./lib/sla').handle(req, res, method, path) // politiques SLA (réponse/résolution par file+priorité), éditables dans Settings
|
||||
if (path.startsWith('/chatwoot/')) return require('./lib/chatwoot').handle(req, res, method, path, url) // Dashboard App Chatwoot : fiche client ERP 360 (panel iframe + lookup protégé par clé)
|
||||
if (path.startsWith('/billing/')) return require('./lib/proration').handle(req, res, method, path) // moteur de prorata (aperçu lignes proratées par type) — gaté staff
|
||||
|
|
@ -260,6 +262,7 @@ const server = http.createServer(async (req, res) => {
|
|||
if (path.startsWith('/gmail')) return require('./lib/gmail').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/supplier-invoices')) return require('./lib/supplier-invoices').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/collab')) return require('./lib/ticket-collab').handle(req, res, method, path, url)
|
||||
if (path.startsWith('/staff-agent')) return require('./lib/staff-agent').handle(req, res, method, path) // copilote OPS langage naturel (function-calling) — plan/confirm/run
|
||||
if (path.startsWith('/dispatch')) return dispatch.handle(req, res, method, path)
|
||||
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').handle(req, res, method, path)
|
||||
// Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user