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>
This commit is contained in:
louispaulb 2026-07-09 20:43:50 -04:00
parent cfcd7c04cc
commit 14530787bb
49 changed files with 2539 additions and 180 deletions

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

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

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

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

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

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

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

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

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

@ -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">
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
<!-- Nom complet éditable (source Authentik, affichée partout). -->
<div v-if="!editingName" class="row items-center no-wrap">
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div>
<q-btn v-if="can('manage_users')" flat round dense size="sm" icon="edit"
color="grey-6" class="q-ml-xs" @click="startEditName(selectedUser)">
<q-tooltip>Modifier le nom complet</q-tooltip>
</q-btn>
</div>
<div v-else class="row items-center no-wrap q-gutter-xs" style="max-width:420px">
<q-input v-model="nameDraft" dense outlined autofocus class="col"
:disable="savingName" @keyup.enter="commitEditName(selectedUser)"
@keyup.esc="editingName = false" />
<q-btn round dense unelevated color="primary" icon="check" :loading="savingName"
@click="commitEditName(selectedUser)" />
<q-btn round dense flat icon="close" :disable="savingName" @click="editingName = false" />
</div>
<div class="text-caption text-grey-6">{{ selectedUser.email }}</div>
<div v-if="selectedUser.last_login" class="text-caption text-grey-5">
Derniere connexion: {{ formatDate(selectedUser.last_login) }}
</div>
</div>
<q-btn flat round dense icon="close" @click="selectedUser = null" />
<q-btn flat round dense icon="close" @click="selectedUser = null; editingName = false" />
</div>
<!-- Profil employé lié (ERPNext) lien session Employee -->
<div class="q-mb-md emp-card">
<div class="row items-center q-mb-xs">
<div class="text-subtitle2"><q-icon name="badge" size="18px" class="q-mr-xs" />Profil employé</div>
<q-space />
<q-spinner v-if="empResolving" size="16px" color="primary" />
</div>
<template v-if="!empResolving">
<div v-if="selectedEmployee" class="row items-center no-wrap">
<div class="col">
<div class="text-body2 text-weight-medium">{{ selectedEmployee.employee_name }}</div>
<div class="text-caption text-grey-6">
{{ [selectedEmployee.designation, selectedEmployee.department].filter(Boolean).join(' · ') || '—' }}
<span v-if="selectedEmployee.cell_number"> · {{ selectedEmployee.cell_number }}</span>
</div>
</div>
<q-btn dense flat no-caps color="primary" icon="edit" label="Modifier"
@click="openEmpEdit(selectedEmployee.name)" />
</div>
<div v-else class="row items-center no-wrap">
<div class="col text-caption text-grey-6"><q-icon name="link_off" size="14px" /> Aucun profil employé lié à ce compte.</div>
<q-btn dense unelevated no-caps color="primary" icon="link" label="Lier un employé"
@click="openEmpLink(selectedUser.email)" />
</div>
</template>
</div>
<!-- Groups assignment -->
@ -255,6 +303,9 @@
</q-expansion-item>
</div>
</q-slide-transition>
<EmployeeEditDialog v-model="empDlgOpen" :employee-name="empDlgName" :link-email="empDlgLinkEmail"
@saved="onEmployeeSaved" />
</div>
<!-- TAB: Groupes -->
@ -381,9 +432,7 @@
</div>
<div v-else>
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs q-px-sm member-row">
<q-avatar size="28px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:0.7rem">
{{ initial(m) }}
</q-avatar>
<UserAvatar :email="m.email" :name="m.name || m.username" :size="28" class="q-mr-sm" />
<span class="text-body2">{{ m.name || m.username }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
<q-space />
@ -411,9 +460,7 @@
<div v-if="memberSearchResults.length" class="member-search-dropdown">
<div v-for="u in memberSearchResults" :key="u.pk" class="member-search-item"
@click="addUserToCurrentGroup(u)">
<q-avatar size="24px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:0.65rem">
{{ initial(u) }}
</q-avatar>
<UserAvatar :email="u.email" :name="u.name || u.username" :size="24" class="q-mr-sm" />
<span class="text-body2">{{ u.name || u.username }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</span>
<q-badge v-if="u.groups.includes(selectedGroup)" label="deja membre" color="grey-3" text-color="grey-6" class="q-ml-auto" />
@ -557,7 +604,7 @@
</div>
<div class="text-caption text-grey-6 q-mb-md">
La configuration SMTP (Mailjet) se fait dans ERPNext.
<a :href="erpDeskUrl + '/app/email-account'" target="_blank">Configurer</a>
<a v-if="can('manage_settings')" :href="erpDeskUrl + '/app/email-account'" target="_blank">Configurer</a>
</div>
<q-separator class="q-my-md" />
@ -710,8 +757,8 @@
</q-expansion-item>
</div>
<!-- SECTION 8: Liens rapides -->
<div class="ops-card">
<!-- SECTION 8: Liens rapides (console admin/infra ERPNext desk, n8n, Authentik) : admins seulement (manage_settings) -->
<div v-if="can('manage_settings')" class="ops-card">
<q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7">
<template #header>
<SectionHeader icon="launch" label="Liens rapides" />
@ -749,6 +796,7 @@
<script setup>
import { ref, reactive, onMounted, watch, defineAsyncComponent, h, resolveComponent } from 'vue'
import { useRoute } from 'vue-router'
import { formatDateTimeShort as formatDate } from 'src/composables/useFormatters' // date+heure canonique (source unique)
import { Notify } from 'quasar'
import { authFetch } from 'src/api/auth'
@ -758,6 +806,10 @@ import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/compos
import { usePermissions } from 'src/composables/usePermissions'
import { usePermissionMatrix } from 'src/composables/usePermissionMatrix'
import { useUserGroups } from 'src/composables/useUserGroups'
import { useAvatar } from 'src/composables/useAvatar'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import EmployeeEditDialog from 'src/components/shared/EmployeeEditDialog.vue'
import { listDocs } from 'src/api/erp'
import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
import CannedResponsesCard from 'src/components/settings/CannedResponsesCard.vue'
@ -804,15 +856,67 @@ const {
const {
userSearch, userResults, userSearchLoading, userSearchDone,
selectedUser, savingGroups,
selectedUser, savingGroups, savingName,
selectedGroup, groupMembers, groupMembersLoading,
addMemberSearch, memberSearchResults, memberSearchLoading,
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup,
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, saveUserName,
selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, addUserToCurrentGroup,
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite,
} = useUserGroups({ permGroups })
// Édition du nom complet (dans le panneau détail utilisateur)
const editingName = ref(false)
const nameDraft = ref('')
function startEditName (user) {
nameDraft.value = user.name || ''
editingName.value = true
}
async function commitEditName (user) {
const ok = await saveUserName(user, nameDraft.value)
if (ok) editingName.value = false
}
// Profil employé lié au compte sélectionné (session Employee)
const selectedEmployee = ref(null)
const empResolving = ref(false)
const empDlgOpen = ref(false)
const empDlgName = ref('')
const empDlgLinkEmail = ref('')
async function resolveSelectedEmployee () {
selectedEmployee.value = null
const email = selectedUser.value?.email
if (!email) return
empResolving.value = true
try {
const fields = ['name', 'employee_name', 'designation', 'department', 'cell_number', 'office_extension', 'company_email', 'user_id']
let rows = await listDocs('Employee', { filters: { user_id: email }, fields, limit: 1 })
if (!rows.length) rows = await listDocs('Employee', { filters: { company_email: email }, fields, limit: 1 })
selectedEmployee.value = rows[0] || null
} catch { selectedEmployee.value = null } finally { empResolving.value = false }
}
watch(selectedUser, resolveSelectedEmployee)
function openEmpEdit (name) { empDlgLinkEmail.value = ''; empDlgName.value = name; empDlgOpen.value = true }
function openEmpLink (email) { empDlgName.value = ''; empDlgLinkEmail.value = email; empDlgOpen.value = true }
function onEmployeeSaved () { resolveSelectedEmployee() }
// Ouverture directe d'un utilisateur via ?user=<email> (bouton « Éditer »
// depuis la page Équipe). On force l'onglet Utilisateurs, on recherche par
// courriel puis on sélectionne la correspondance exacte.
const route = useRoute()
async function openUserByEmail (email) {
const target = String(email || '').trim().toLowerCase()
if (!target) return
permTab.value = 'users'
userSearch.value = target
await searchUsers()
const match = userResults.value.find(u => (u.email || '').toLowerCase() === target)
if (match) selectUser(match)
else Notify.create({ type: 'warning', message: `Aucun compte trouvé pour ${target}`, timeout: 3000 })
}
async function copyTempPassword () {
if (!lastInviteResult.value?.temp_password) return
try {
@ -854,8 +958,24 @@ function notify (msg, type = 'positive', timeout = 1500) {
Notify.create({ type, message: msg, timeout })
}
function initial (u) {
return (u.name || u.username || '?')[0].toUpperCase()
// Avatar d'équipe : un admin peut définir la photo d'un membre (upload via ?as=).
const { uploadAvatar, fileToAvatarDataUrl } = useAvatar()
const teamAvatarInput = ref(null)
let teamAvatarTarget = null
function pickTeamAvatar (user) {
teamAvatarTarget = user
teamAvatarInput.value?.click()
}
async function onTeamAvatarFile (e) {
const file = e.target.files && e.target.files[0]
e.target.value = ''
if (!file || !teamAvatarTarget) return
try {
const dataUrl = await fileToAvatarDataUrl(file)
await uploadAvatar(dataUrl, { as: teamAvatarTarget.email })
} catch (err) {
Notify.create({ type: 'negative', message: 'Image illisible: ' + err.message })
}
}
// formatDate useFormatters.formatDateTimeShort (date+heure canonique)
@ -905,14 +1025,16 @@ onMounted(async () => {
}
if (isLoaded.value && can('manage_permissions')) {
loadPerms()
searchUsers()
if (route.query.user) openUserByEmail(String(route.query.user))
else searchUsers()
}
})
watch(isLoaded, (loaded) => {
if (loaded && can('manage_permissions') && !permGroups.value.length) {
loadPerms()
searchUsers()
if (route.query.user) openUserByEmail(String(route.query.user))
else searchUsers()
}
}, { immediate: false })
@ -973,6 +1095,11 @@ async function testSms () {
.user-card--selected { background: #eef2ff; border-left: 3px solid #6366f1; }
.user-detail-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; }
.ua-edit { position: relative; display: inline-flex; }
.ua-edit--can { cursor: pointer; }
.ua-edit__cam { position: absolute; right: -2px; bottom: -2px; width: 18px; height: 18px; border-radius: 50%;
background: var(--ops-primary, #16a34a); display: flex; align-items: center; justify-content: center;
border: 2px solid #fff; }
.group-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
.group-card { padding: 14px; border: 1px solid #e2e8f0; border-radius: 10px; cursor: pointer; transition: all 0.15s; }

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

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

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