Compare commits

...

10 Commits

Author SHA1 Message Date
louispaulb
14530787bb consolidate uncommitted multi-session work (NL staff-agent, user avatars/profil, HelpHint, ticket detail modules, skill icons, hub agent/payments/sync)
Lot de travaux accumulés non commités (plusieurs sessions), vérifiés : hub `node --check`
tout OK + build SPA propre.
- ops : AssignmentField, CommandPalette, IssueDetail/DetailModal + detail-sections/modules,
  UserAvatar/useAvatar, UserProfileDialog/EmployeeEditDialog, HelpHint, useSkillIcons,
  pages (Dashboard/Equipe/Clients/Tickets/Reports/Settings/Evaluations/LegacySync…),
  usePermissions/useUserGroups/useDetailModal, MainLayout, TicketStatusControl
- hub : staff-agent.js (couche commandes NL), agent.js/agent-tools.json/voice-agent.js,
  avatars.js, conversation.js, payments.js, sync-orchestrator.js, auth.js, campaigns.js,
  legacy-dispatch-sync.js, server.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:43:50 -04:00
louispaulb
cfcd7c04cc feat(ops/planif): add JobPool + useJobPool + RouteMap route/explode
Completes the jobs-to-assign pool unification whose PlanificationPage wiring
landed in 9c49054 (which referenced these files before they were committed):
- JobPool.vue: one shared component (floating/docked/mobile) replacing the 3
  divergent pool implementations
- useJobPool.js: shared filter/sort/group/badge composable
- RouteMap.vue: live tech icons join the explode/fan; single-tech real Traccar
  day-route layer

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:30:36 -04:00
louispaulb
9c49054b8d feat(ops): quick filler job from cell menu (blocks tech from dispatch)
Add "Bloquer / job générique…" to the timeline cell menu (opens on empty-cell
click or right-click). Opens a small dialog prefilled with the tech + day:
editable title, full-day toggle (= that cell's shift length) or custom
start/duration, standard priority (default Moyenne), type, and a "generate a
linked ticket" toggle.

New hub POST /roster/filler-job creates a standard-priority assigned Dispatch
Job (+ optional ERPNext Issue via source_issue, best-effort so a ticket failure
never blocks the job). Since timed jobs now count toward occupancy, this removes
the tech from regular dispatch — unlike /reserve which stays soft for urgencies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:58:18 -04:00
louispaulb
9145624949 feat(ops): unify tech skill/cadence editor + merge team tools
Extract the per-tech skill editor (chips ordered by priority + per-skill Score
★ + Cadence %) into shared components/planif/SkillCadenceTable.vue, used by BOTH
the click-a-tech popover AND the team tool — one template everywhere.

Rebuild "Équipe — cadence & coût" into "Équipe — compétences, cadence & coût":
each tech is an expandable row showing SkillCadenceTable (per-skill cadence) +
global cadence (default 100%) + cost. Operates on live tech objects so edits
persist via the existing handlers. Folds "Gérer les compétences" (tag catalog:
rename/recolor/delete) in as a collapsible section, and collapses the two menu
entries into one "Équipe — compétences · cadence · coût".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:57:01 -04:00
louispaulb
04fb52426f fix(ops): slot finder respects untimed load (humanly possible)
suggestSlots built gaps only from timed jobs, so a tech loaded with untimed
(no start_time) legacy jobs still looked free and got offered slots. Now the
day is skipped when timed + untimed committed hours + the requested duration
exceed the shift. Also matches assigned_tech by technician_id OR docname.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:43:21 -04:00
louispaulb
7df6ad30e3 fix(ops): accurate occupancy denominator + editable skills + support filter
Three fixes from live feedback:
1. techOccupancy counted only jobs WITH a start_time → legacy osTicket jobs
   (assigned, no appointment hour) showed as free (e.g. Houssam "40h libre"
   with 6 jobs). Now untimed assigned jobs count toward busy (rendered as
   hatched "unscheduled" blocks) and jobs match by technician_id OR docname.
   techOccupancy also accepts CSV skills (require ALL) for multi-skill needs.
2. Support now maps to a 'support' skill (was []), so it lists only
   support-skilled agents — techs with no skills set are excluded. Added
   'support' to SKILL_VOCAB + DEPARTMENT_SKILLS.
3. AvailabilityByReason skills list is editable: auto-added skills (e.g.
   'sans-fil' from wireless service address) can be removed per-chip when
   not relevant. Bands + summary now reflect ALL selected skills.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:28:42 -04:00
louispaulb
1552df3984 Campaign detail: fix customer link pointing to ERPNext instead of Ops
The recipient row's customer link was href="/#/clients/<id>" — with the app on
hash routing served under /ops/, a leading slash before # resolves against the
site root, dropping /ops/ and landing on ERPNext desk. Drop the leading slash
so it stays scoped under the current /ops/ path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:57:54 -04:00
louispaulb
2a9f201826 Events: personal rsvp_url per-language, address field, logo spacing
- rsvpLink() now appends &lang=<en|fr> when generating a recipient's personal
  link, so the English invitation's link opens the English RSVP form instead
  of defaulting to French. Threaded through sendTest and the mass-send worker.
- New optional per-language "address" field (editor input, SEED default for
  fete-20-ans, Unlayer template var), rendered under the date with a pin icon
  in both the festive email and the RSVP page.
- Logo: +20px bottom margin in both the email template and the RSVP page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 13:26:45 -04:00
louispaulb
cab1a9c94a Events: wire mass-send button + confirmation dialog into the UI
Frontend half of the campaign-integrated mass send: an "Envoyer à N
destinataire(s)" button in the send dialog's mass tab, gated behind a
confirmation dialog that shows the exact recipient count and channel before
firing. On success, links to the created campaign in /campaigns for tracking.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:41:34 -04:00
louispaulb
929ef73d93 Events: real mass send (campaign-integrated) + white email header bar
- Mass send: "Envoyer à N destinataire(s)" button behind a confirmation dialog
  showing the exact recipient count. Creates a real campaign record (reuses
  campaigns.newCampaignId/loadCampaign/saveCampaign — 3 new exports, no worker
  changes) so Mailjet open/click tracking, per-recipient status, report.csv, and
  the /campaigns list all work unchanged. Events owns the actual send loop
  (per-recipient personal rsvp_url, per-language attachments, festive/Unlayer
  template) since the generic campaign worker doesn't support those.
- Festive email template: top accent bar changed from TARGO blue to white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 12:39:50 -04:00
65 changed files with 3672 additions and 644 deletions

View File

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

View File

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

View 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" />&nbsp;À 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) }" />&nbsp;{{ 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&nbsp;{{ 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>

View File

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

View 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&nbsp;%</b> = normale · <b>200&nbsp;%</b> = deux fois plus de jobs · &lt;&nbsp;100&nbsp;% = 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>

View File

@ -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&nbsp;:
<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'

View File

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

View File

@ -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 à lassistant : « ${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(() => {

View File

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

View File

@ -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'])

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

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

View File

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

View File

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

View File

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

View File

@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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; } }

View File

@ -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">&nbsp; Reporter</q-item-label>
<q-item-label header class="q-py-xs">&nbsp; 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'

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -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(() => []),
)

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

View File

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

View 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'
}

View File

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

View File

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

View File

@ -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(() =>

View File

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

View File

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

View File

@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (FOPS), 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 (FOPS 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(' · ')

View File

@ -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" />&nbsp;À 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) }" />&nbsp;{{ 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 &amp; 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 &amp; 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é&nbsp;%</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&nbsp;%</b> = normale · <b>200&nbsp;%</b> = deux fois plus de jobs · &lt;&nbsp;100&nbsp;% = 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&nbsp;{{ 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>
<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">
</div>
<q-space />
<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>
</AssignmentField>
</div>
<q-space />
<q-btn flat round dense icon="close" v-close-popup />
</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 « -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; }

View File

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

View File

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

View File

@ -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">
<!-- 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; }

View File

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

View File

@ -144,7 +144,7 @@
</template>
<template #title-suffix>
<span> &middot; <span class="text-primary">#{{ modalTicket?.legacy_ticket_id || modalTicket?.name }}</span></span>
<template v-if="modalTicket?.customer_name"> &middot; {{ modalTicket.customer_name }}</template>
<template v-if="modalTicket?.customer_name"> &middot; <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'"

View File

@ -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"] } } }
]

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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">&nbsp;</td></tr>`
+ `<tr><td style="height:10px;background:#fff;font-size:0;line-height:0">&nbsp;</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 }

View File

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

View File

@ -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') {

View File

@ -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' })

View File

@ -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: [],

View File

@ -0,0 +1,324 @@
'use strict'
/**
* staff-agent.js Copilote OPS en langage naturel (Gemini function-calling), surface STAFF.
*
* BUT (parité UINL) : 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 }

View File

@ -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' }) {

View File

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

View File

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