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 }" :input-style="{ color: statusColor }"
style="min-width:100px;max-width:140px;text-align:right" style="min-width:100px;max-width:140px;text-align:right"
@update:model-value="saveStatus" /> @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>
</div> </div>
@ -75,7 +67,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import InlineField from 'src/components/shared/InlineField.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' import { updateDoc } from 'src/api/erp'
const contactOpen = ref(false) const contactOpen = ref(false)

View File

@ -9,6 +9,16 @@
Deux bascules agissent sur MOI (l'utilisateur connecté) : m'ajouter comme assistant · suivre. --> Deux bascules agissent sur MOI (l'utilisateur connecté) : m'ajouter comme assistant · suivre. -->
<div class="asg-field"> <div class="asg-field">
<div class="asg-chips row items-center q-gutter-xs"> <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é --> <!-- À : 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-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> <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 { useAuthStore } from 'src/stores/auth'
import { initials as ini, shortAgent } from 'src/composables/useFormatters' import { initials as ini, shortAgent } from 'src/composables/useFormatters'
import TechSelect from 'src/components/shared/TechSelect.vue' import TechSelect from 'src/components/shared/TechSelect.vue'
import HelpHint from 'src/components/shared/HelpHint.vue'
const props = defineProps({ const props = defineProps({
doctype: { type: String, required: true }, // pour le suivi : 'Dispatch Job' | 'Issue' 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 { newDialogOpen, newDialogChannel, openComposeDraft, panelOpen } = useConversations()
const { requestCreate } = useCreateSignal() const { requestCreate } = useCreateSignal()
const { open, contextActions } = useCommandPalette() const { open, contextActions } = useCommandPalette()
const emit = defineEmits(['nl']) // commande en langage naturel MainLayout ouvre le copilote STAFF
const inputEl = ref(null) const inputEl = ref(null)
const query = ref('') 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 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 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 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 return out
}) })
// Rang des groupes GLOBAUX ; tout groupe inconnu (= action de page) passe en tête (0). // 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` // 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). // la navigation clavier suit exactement l'ordre visuel (pas de saut).
const grouped = computed(() => { 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 --> <!-- 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" :class="{ 'msg-head-open': isExpanded(msg) }" @click="toggleMsg(msg)">
<div class="msg-head-line1 row items-center no-wrap"> <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 --> <!-- Avatar (photo sinon initiales) + nom par message on voit QUI a écrit.
<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> 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" /> <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> <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="msgWho(msg) && (activeDiscussion.messages || []).length > 1" class="msg-who">{{ msgWho(msg) }}</span> <span v-else-if="showMsgWho(msg)" class="msg-who">{{ msgWho(msg) }}</span>
<q-space /> <q-space />
<!-- Actions du message SUR la ligne d'en-tête (expéditeur) : répondre + agrandir le courriel --> <!-- 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> <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) --> <!-- Ajouter le fil courant à une tâche (réutilisable conversation/ticket/projet) -->
<AddToTaskDialog v-model="addTaskOpen" :source="activeTaskSrc || taskSource" /> <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> </template>
<script setup> <script setup>
@ -1034,10 +1044,16 @@ import RecipientSelect from 'src/components/shared/RecipientSelect.vue'
import { useSoftphone } from 'src/composables/useSoftphone' import { useSoftphone } from 'src/composables/useSoftphone'
import { shortAgent, staffColor, staffInitials, noteAuthorName, relTime } from 'src/composables/useFormatters' import { shortAgent, staffColor, staffInitials, noteAuthorName, relTime } from 'src/composables/useFormatters'
import { recipientLabel, messageIdentity, channelMeta, titleCase, priorityMeta, PRIORITY_LEVELS } from 'src/composables/useConversationDisplay' 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 { useAuthStore } from 'src/stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import AddToTaskDialog from 'src/components/shared/AddToTaskDialog.vue' import AddToTaskDialog from 'src/components/shared/AddToTaskDialog.vue'
import EmailExpandDialog from 'src/components/shared/conversation/EmailExpandDialog.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 AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison compétence disponibilité (dénominateur filtré)
import { emailSrcdoc } from 'src/composables/useEmailRender' 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) 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 // 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 ticketCategories = ['Support', 'Facturation', 'Installation', 'Fibre', 'Télévision', 'Téléphonie', 'Commercial', 'Autre']
const creatingTicket = ref(false) const creatingTicket = ref(false)
const ticketDialog = ref({ open: false, title: '', category: 'Support', priority: 'Medium' }) 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) // 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) const replyOpen = ref(false)
function collapseAllMsgs () { expandedMsgs.value = new Set() } 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. // Citation ÉDITABLE du message auquel on répond (en-tête + contenu), style Gmail l'agent écrit au-dessus.
function quotedBlock (m) { 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 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 dt = src ? new Date(src) : null
const when = (dt && !isNaN(dt.getTime())) ? formatDate(src) : '' // illisible on OMET la date (plus de « Le Invalid Date, ») 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. // Bloc « Message transféré » (en-têtes De/Date/Objet/À + corps), style Gmail.
function forwardedBlock (m) { function forwardedBlock (m) {
if (!m) return '' 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 when = msgFullDate(m) || relTime(m.emailDate || m.ts)
const subj = convSubject.value || activeDiscussion.value?.lastSubject || '' const subj = convSubject.value || activeDiscussion.value?.lastSubject || ''
const to = msgTo(m) 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' }) } 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). // 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 } 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 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 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) 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 }) $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 } } 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 // Inbox unifié : filtre (conversations + tickets) + helpers d'affichage des tickets
const inboxFilter = ref('all') // 'all' | 'conv' | 'tickets' const inboxFilter = ref('all') // 'all' | 'conv' | 'tickets'

View File

@ -15,7 +15,9 @@
</div> </div>
</div> </div>
<slot name="header-actions" /> <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-btn flat round dense icon="close" @click="$emit('update:open', false)" />
</q-card-section> </q-card-section>
@ -29,7 +31,7 @@
<component :is="sectionComponent" v-if="sectionComponent" <component :is="sectionComponent" v-if="sectionComponent"
:doc="doc" :doc-name="docName" :title="title" :doc="doc" :doc-name="docName" :title="title"
:comments="comments" :comms="comms" :files="files" :comments="comments" :comms="comms" :files="files"
:dispatch-jobs="dispatchJobs" :dispatch-jobs="dispatchJobs" :hide-customer-link="hideCustomerLink"
@navigate="(...a) => $emit('navigate', ...a)" @navigate="(...a) => $emit('navigate', ...a)"
@reply-sent="(...a) => $emit('reply-sent', ...a)" @reply-sent="(...a) => $emit('reply-sent', ...a)"
@save-field="(...a) => $emit('save-field', ...a)" @save-field="(...a) => $emit('save-field', ...a)"
@ -93,6 +95,7 @@ const props = defineProps({
files: { type: Array, default: () => [] }, files: { type: Array, default: () => [] },
docFields: { type: Object, default: () => ({}) }, docFields: { type: Object, default: () => ({}) },
dispatchJobs: { type: Array, 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']) 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 style="min-width:540px;max-width:96vw">
<q-card-section class="row items-center q-pb-none"> <q-card-section class="row items-center q-pb-none">
<q-icon name="bolt" color="deep-purple-6" size="22px" class="q-mr-sm" /> <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-space />
<q-btn flat round dense icon="close" v-close-popup /> <q-btn flat round dense icon="close" v-close-popup />
</q-card-section> </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" /> <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> </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) --> <!-- Vérifications (lecture immédiate pas de confirmation) -->
<div v-if="diagnoses.length" class="q-mt-md"> <div v-if="diagnoses.length" class="q-mt-md">
<div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag"> <div v-for="(a, i) in diagnoses" :key="'d' + i" class="orch-diag">
@ -72,6 +75,9 @@
<q-space /> <q-space />
<q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" /> <q-btn flat round dense size="xs" icon="delete" color="grey-5" @click="actions.splice(actions.indexOf(a), 1)" />
</div> </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 --> <!-- destinataire / client résolu -->
<div v-if="a.candidates && a.candidates.length" class="q-mb-xs"> <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 <q-select dense outlined v-model="a.resolved" :options="a.candidates" option-label="customer_name" map-options emit-value
@ -122,11 +128,22 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub' 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 emit = defineEmits(['update:modelValue', 'done'])
const $q = useQuasar() const $q = useQuasar()
@ -134,6 +151,14 @@ const open = ref(false)
const text = ref('') const text = ref('')
const actions = ref([]) const actions = ref([])
const results = 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 planning = ref(false)
const running = ref(false) const running = ref(false)
const listening = 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 } } 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)) 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 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' }[t] || 'grey-7') 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' }[t] || t) 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) { function summaryOf (r) {
if (r.type === 'create_ticket') return r.ok ? `Ticket ${r.name} créé (${r.label || ''})` : 'Ticket : ' + (r.error || 'échec') 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_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 === 'send_email') return r.ok ? `Courriel envoyé à ${r.to}` : 'Courriel : ' + (r.error || 'échec')
if (r.type === 'note') return 'Note : ' + (r.text || '') 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 () { function toggleMic () {
@ -179,12 +205,13 @@ async function plan () {
if (!text.value.trim()) return if (!text.value.trim()) return
planning.value = true; results.value = [] planning.value = true; results.value = []
try { 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() const d = await r.json()
if (d.ok) { if (d.ok) {
actions.value = d.actions || [] 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 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' }) } else $q.notify({ type: 'negative', message: d.error || 'Échec' })
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { planning.value = false } } 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 () { async function run () {
running.value = true running.value = true
try { 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() const d = await r.json()
results.value = d.results || [] results.value = d.results || []
const ok = results.value.filter(x => x.ok).length 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-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; } .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; } .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> </style>

View File

@ -1,17 +1,17 @@
<template> <template>
<div v-if="others.length" class="rstack"> <div v-if="others.length" class="rstack">
<q-avatar v-for="r in head" :key="r.email" :size="size + 'px'" class="rstack-av" <UserAvatar v-for="r in head" :key="r.email" :email="r.email" :name="noteAuthorName(r.email)"
:style="{ background: staffColor(r.email), color: '#fff', fontSize: Math.round(size * 0.42) + 'px' }"> :size="size" class="rstack-av">
{{ staffInitials(noteAuthorName(r.email)) }}
<q-tooltip>{{ shortAgent(r.email) }}<template v-if="r.readAt"> · {{ relTime(r.readAt) }}</template></q-tooltip> <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> <span v-if="extra > 0" class="rstack-more">+{{ extra }}</span>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' 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({ const props = defineProps({
readers: { type: Array, default: () => [] }, // [{ email, readAt }] readers: { type: Array, default: () => [] }, // [{ email, readAt }]

View File

@ -5,7 +5,7 @@
Doctype-conscient : Issue (support) ou Dispatch Job (terrain). --> 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-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-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(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="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> <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 { computed, ref } from 'vue'
import { Dialog, Notify } from 'quasar' import { Dialog, Notify } from 'quasar'
import { updateDoc, createDoc } from 'src/api/erp' import { updateDoc, createDoc } from 'src/api/erp'
import HelpHint from 'src/components/shared/HelpHint.vue'
const props = defineProps({ const props = defineProps({
doctype: { type: String, required: true }, // 'Issue' | 'Dispatch Job' 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> <template>
<!-- Quick actions --> <!-- 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"> <div class="issue-actions q-mb-sm">
<TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status" <TicketStatusControl doctype="Issue" :docname="docName" :status="doc.status"
@changed="p => { if (p && p.status) doc.status = p.status; $emit('reply-sent', docName) }" /> @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> </div>
<!-- Client lié (comme une conversation : cherchable par nom, association 1 clic) --> <!-- Client lié (comme une conversation : cherchable par nom, association 1 clic).
<div class="cust-banner q-mb-sm"> 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'" /> <q-icon name="person" size="18px" :color="doc.customer ? 'green-7' : 'grey-5'" />
<template v-if="doc.customer"> <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> <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>
</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="q-mt-sm">
<div class="info-block-title">Sujet</div> <div class="info-block-title">Sujet</div>
<InlineField :value="doc.subject" field="subject" doctype="Issue" :docname="docName" <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 { useTagCatalog } from 'src/composables/useTagCatalog'
import InlineField from 'src/components/shared/InlineField.vue' import InlineField from 'src/components/shared/InlineField.vue'
import UnifiedCreateModal from 'src/components/shared/UnifiedCreateModal.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 TicketStatusControl from 'src/components/shared/TicketStatusControl.vue'
import ProjectWizard from 'src/components/shared/ProjectWizard.vue' import ProjectWizard from 'src/components/shared/ProjectWizard.vue'
import TaskNode from 'src/components/shared/TaskNode.vue' import TaskNode from 'src/components/shared/TaskNode.vue'
import TagEditor from 'src/components/shared/TagEditor.vue' import TagEditor from 'src/components/shared/TagEditor.vue'
import AssignmentField from 'src/components/shared/AssignmentField.vue' import AssignmentField from 'src/components/shared/AssignmentField.vue'
import AvailabilityByReason from 'src/components/shared/AvailabilityByReason.vue' // raison compétence disponibilité (dénominateur filtré) 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({ const props = defineProps({
doc: { type: Object, required: true }, doc: { type: Object, required: true },
@ -324,6 +361,7 @@ const props = defineProps({
comms: { type: Array, default: () => [] }, comms: { type: Array, default: () => [] },
files: { type: Array, default: () => [] }, files: { type: Array, default: () => [] },
dispatchJobs: { 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']) const emit = defineEmits(['navigate', 'reply-sent', 'dispatch-created', 'dispatch-deleted', 'dispatch-updated', 'deleted'])
@ -512,6 +550,68 @@ async function saveAssignees (newList) {
onMounted(() => { loadUsers(); loadTagCatalog() }) 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 // Tags catalogue UNIFIÉ (Dispatch Tag compétences roster) via TagEditor
// Le catalogue et son CRUD vivent dans useTagCatalog (source unique, réutilisable). // 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() 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(() => []), }).catch(() => []),
listDocs('Dispatch Job', { listDocs('Dispatch Job', {
filters: { source_issue: name }, 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', limit: 50, orderBy: 'step_order asc, creation asc',
}).catch(() => []), }).catch(() => []),
) )

View File

@ -87,6 +87,8 @@ function hasGroup (groupName) {
const userGroups = computed(() => permissions.value?.groups || []) const userGroups = computed(() => permissions.value?.groups || [])
const userName = computed(() => permissions.value?.name || '') const userName = computed(() => permissions.value?.name || '')
const userEmail = computed(() => permissions.value?.email || '') 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 isSuperuser = computed(() => permissions.value?.is_superuser || false)
const isAdmin = computed(() => hasGroup('admin') || isSuperuser.value) const isAdmin = computed(() => hasGroup('admin') || isSuperuser.value)
const isLoaded = computed(() => permissions.value !== null) const isLoaded = computed(() => permissions.value !== null)
@ -103,6 +105,7 @@ export function usePermissions () {
userGroups, userGroups,
userName, userName,
userEmail, userEmail,
userEmployee,
isSuperuser, isSuperuser,
isAdmin, isAdmin,
isLoaded, 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 userSearchDone = ref(false)
const selectedUser = ref(null) const selectedUser = ref(null)
const savingGroups = ref(false) const savingGroups = ref(false)
const savingName = ref(false)
// Groups tab // Groups tab
const selectedGroup = ref(null) 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) { async function selectGroup (name) {
if (selectedGroup.value === name) { if (selectedGroup.value === name) {
selectedGroup.value = null selectedGroup.value = null
@ -236,10 +267,10 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
return { return {
userSearch, userResults, userSearchLoading, userSearchDone, userSearch, userResults, userSearchLoading, userSearchDone,
selectedUser, savingGroups, selectedUser, savingGroups, savingName,
selectedGroup, groupMembers, groupMembersLoading, selectedGroup, groupMembers, groupMembersLoading,
addMemberSearch, memberSearchResults, memberSearchLoading, addMemberSearch, memberSearchResults, memberSearchLoading,
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, saveUserName,
selectGroup, loadGroupMembers, removeFromGroup, selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup, debouncedMemberSearch, searchMembersToAdd, addUserToCurrentGroup,
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite, 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-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-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Agrandir le menu</q-tooltip>
</q-item> </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 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 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-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]">{{ userName || auth.user || 'Déconnexion' }}</q-tooltip> <q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Déconnexion</q-tooltip>
</q-item> </q-item>
</div> </div>
</q-drawer> </q-drawer>
@ -67,6 +72,9 @@
<q-space /> <q-space />
<NotificationBell v-if="can('view_clients')" dark /> <NotificationBell v-if="can('view_clients')" dark />
<q-btn v-if="!isDispatch" flat round dense icon="search" color="white" @click="mobileSearchOpen = !mobileSearchOpen" /> <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> </q-toolbar>
<div v-if="mobileSearchOpen && !isDispatch" class="q-px-sm q-pb-sm" style="background:var(--ops-sidebar-bg)"> <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" <q-input v-model="searchQuery" placeholder="Rechercher client, adresse..." dense outlined dark autofocus class="ops-search-dark"
@ -122,6 +130,9 @@
</div> </div>
</div> </div>
<NotificationBell v-if="can('view_clients')" class="q-ml-sm" /> <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> </div>
<router-view /> <router-view />
</q-page-container> </q-page-container>
@ -154,8 +165,12 @@
</div> </div>
</q-page-sticky> </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" /> <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 --> <!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) lecture -->
<ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" /> <ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" />
<!-- Tampon d'envoi : courriels multi-destinataires retenus + compte à rebours + annuler (anti-spam) --> <!-- 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) --> <!-- Global Flow Editor dialog (any page can open it via useFlowEditor) -->
<FlowEditorDialog v-if="can('manage_settings')" /> <FlowEditorDialog v-if="can('manage_settings')" />
<!-- Palette de commandes globale (Cmd/Ctrl+K) : actions + pages + actions de page + clients/équipe --> <!-- Palette de commandes globale (Cmd/Ctrl+K) : actions + pages + actions de page + clients/équipe + langage naturel -->
<CommandPalette v-if="can('view_clients')" /> <CommandPalette v-if="can('view_clients')" @nl="onNlCommand" />
<UserProfileDialog v-model="profileOpen" />
</q-layout> </q-layout>
</template> </template>
@ -189,6 +206,8 @@ const ConversationPanel = defineAsyncComponent(() => import('src/components/shar
const PhoneModal = defineAsyncComponent(() => import('src/components/customer/PhoneModal.vue')) const PhoneModal = defineAsyncComponent(() => import('src/components/customer/PhoneModal.vue'))
import { useSoftphone } from 'src/composables/useSoftphone' import { useSoftphone } from 'src/composables/useSoftphone'
import NotificationBell from 'src/components/shared/NotificationBell.vue' 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 { useConversations } from 'src/composables/useConversations'
import { useCreateSignal } from 'src/composables/useCreateSignal' import { useCreateSignal } from 'src/composables/useCreateSignal'
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue')) const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
@ -213,6 +232,11 @@ const orchestratorOpen = ref(false)
const serviceStatusOpen = ref(false) const serviceStatusOpen = ref(false)
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true } 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 // Palette de commandes globale (Cmd/Ctrl+K) escape hatch pour tout ce qu'on déclutter
const { togglePalette, openPalette } = useCommandPalette() const { togglePalette, openPalette } = useCommandPalette()
function onGlobalKey (e) { function onGlobalKey (e) {
@ -222,7 +246,8 @@ onMounted(() => window.addEventListener('keydown', onGlobalKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKey)) onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKey))
const auth = useAuthStore() 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 // Filter nav items based on user capabilities
const navItems = computed(() => 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="t-caption" style="color:var(--tg-700);font-weight:700">{{ job?.name || '…' }}</div>
<div class="topbar-subject">{{ job?.subject || 'Job' }}</div> <div class="topbar-subject">{{ job?.subject || 'Job' }}</div>
</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> </header>
<!-- Loading state --> <!-- Loading state -->
@ -267,7 +254,6 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { getDoc, listDocs, createDoc, updateDoc } from 'src/api/erp' import { getDoc, listDocs, createDoc, updateDoc } from 'src/api/erp'
import { BASE_URL } from 'src/config/erpnext'
import { HUB_URL } from 'src/config/hub' import { HUB_URL } from 'src/config/hub'
const route = useRoute() const route = useRoute()
@ -276,7 +262,6 @@ const router = useRouter()
const jobName = computed(() => route.params.name) const jobName = computed(() => route.params.name)
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const menuOpen = ref(false)
const job = ref(null) const job = ref(null)
const locationDetail = ref(null) const locationDetail = ref(null)
const equipment = ref([]) const equipment = ref([])
@ -434,10 +419,6 @@ function openGps () {
window.open(`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(addr)}`, '_blank') 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 () { function goScan () {
router.push({ router.push({
name: 'tech-scan', name: 'tech-scan',

View File

@ -24,18 +24,13 @@
</q-list> </q-list>
</q-menu> </q-menu>
</q-btn> </q-btn>
<q-btn dense flat size="sm" no-caps color="teal-8" icon="event_available" label="Trouver un créneau" @click="openFindSlot"> <q-btn dense flat size="sm" no-caps color="teal-8" icon="event_available" label="Trouver un créneau" @click="openAvailReason">
<q-tooltip>Prendre RDV (réparation / installation) pré-rempli avec l'adresse du client</q-tooltip> <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>
<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> </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> </div>
</template> </template>
<template #contact><ContactCard :customer="customer" /></template> <template #contact><ContactCard :customer="customer" /></template>
@ -344,7 +339,7 @@
</div> </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> <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-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 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-section>
<q-item-label lines="1">{{ commsTitle(d) }}</q-item-label> <q-item-label lines="1">{{ commsTitle(d) }}</q-item-label>
@ -356,6 +351,9 @@
</q-item-section> </q-item-section>
</q-item> </q-item>
</q-list> </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> </div>
<!-- Journal d'activité : tickets, interventions, appels, système. Les courriels/SMS sont dans « Messagerie » ci-dessus (pas re-dupliqués ici). --> <!-- 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"> <div class="ops-card q-pa-sm q-mb-md" style="border-radius:12px">
@ -366,7 +364,7 @@
</div> </div>
<div v-if="!activityLog.length" class="text-center text-grey-6 q-pa-md text-caption">Aucune activité récente.</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-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 avatar><q-icon :name="r.icon" :color="r.color" size="20px" /></q-item-section>
<q-item-section> <q-item-section>
<q-item-label lines="1">{{ r.label }}</q-item-label> <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-section side><q-item-label caption>{{ formatDate(r.date) }}</q-item-label></q-item-section>
</q-item> </q-item>
</q-list> </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"> <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 - 5) + ' de plus' }} {{ recentOpen ? '▲ Réduire' : '▼ Voir ' + (activityLog.length - 1) + ' de plus' }}
</div> </div>
</div> </div>
<!-- Notes internes (secondaire) Comment ERPNext. --> <!-- 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> <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>
<div v-if="!sortedComments.length" class="text-grey-6 text-caption q-pa-xs">Aucune note</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"> <div class="row items-center no-wrap">
<span class="text-caption text-weight-medium text-warning">{{ noteAuthorName(c.comment_by || c.owner) }}</span> <span class="text-caption text-weight-medium text-warning">{{ noteAuthorName(c.comment_by || c.owner) }}</span>
<q-space /> <q-space />
@ -400,6 +398,9 @@
</div> </div>
<div style="white-space:pre-wrap;font-size:0.85rem;color:#334155">{{ c.content }}</div> <div style="white-space:pre-wrap;font-size:0.85rem;color:#334155">{{ c.content }}</div>
</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> </div>
</div> </div>
@ -598,7 +599,7 @@
:initial-address="findSlotAddr" :initial-skill="findSlotSkill" @select="onFindSlotSelected" /> :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 --> <!-- « 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 || ''" <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" <UnifiedCreateModal v-model="createJobOpen" mode="work-order"
:context="createJobCtx" :locations="locations" @created="onJobCreated" /> :context="createJobCtx" :locations="locations" @created="onJobCreated" />
@ -612,6 +613,7 @@
:doc-name="modalDocName" :title="modalTitle" :doc="modalDoc" :doc-name="modalDocName" :title="modalTitle" :doc="modalDoc"
:comments="modalComments" :comms="modalComms" :files="modalFiles" :comments="modalComments" :comms="modalComms" :files="modalFiles"
:doc-fields="modalDocFields" :dispatch-jobs="modalDispatchJobs" :doc-fields="modalDocFields" :dispatch-jobs="modalDispatchJobs"
:hide-customer-link="true"
@navigate="(dt, name, t) => openModal(dt, name, t)" @navigate="(dt, name, t) => openModal(dt, name, t)"
@open-pdf="openPdf" @save-field="saveSubField" @open-pdf="openPdf" @save-field="saveSubField"
@toggle-recurring="toggleRecurringModal" @dispatch-created="onDispatchCreated" @toggle-recurring="toggleRecurringModal" @dispatch-created="onDispatchCreated"
@ -677,7 +679,33 @@
</q-card-section> </q-card-section>
<q-card-actions align="right"> <q-card-actions align="right">
<q-btn flat no-caps label="Annuler" v-close-popup /> <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-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -691,7 +719,7 @@ import { Notify, useQuasar } from 'quasar'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { deleteDoc, createDoc, listDocs } from 'src/api/erp' import { deleteDoc, createDoc, listDocs } from 'src/api/erp'
import { authFetch } from 'src/api/auth' 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 { HUB_URL } from 'src/config/hub'
import { formatDate, formatDateTime, formatMoney, noteAuthorName } from 'src/composables/useFormatters' import { formatDate, formatDateTime, formatMoney, noteAuthorName } from 'src/composables/useFormatters'
import { invStatusClass } from 'src/composables/useStatusClasses' 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 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 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 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 { DEPARTMENTS } from 'src/config/departments'
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue' import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
import CreateInvoiceModal from 'src/components/shared/CreateInvoiceModal.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) 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. // 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)) 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 () { function recordPayment () {
if (!customer.value) return if (!customer.value) return
const url = ERP_DESK_URL + '/app/payment-entry/new?payment_type=Receive&party_type=Customer&party=' + encodeURIComponent(customer.value.name) onAcctDlg.amount = 0; onAcctDlg.mode = 'Cheque'; onAcctDlg.referenceNo = ''; onAcctDlg.preview = null; onAcctDlg.error = ''
window.open(url, '_blank', 'noopener') 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. // « 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: '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) }, { 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. // 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 activityLog = computed(() => {
const items = [] const items = []

View File

@ -53,7 +53,7 @@
<div class="row q-col-gutter-md"> <div class="row q-col-gutter-md">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<div class="ops-card"> <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"> <div class="row items-center q-gutter-md">
<q-chip :color="schedulerEnabled ? 'positive' : 'negative'" text-color="white" icon="schedule"> <q-chip :color="schedulerEnabled ? 'positive' : 'negative'" text-color="white" icon="schedule">
{{ schedulerEnabled ? 'Actif' : 'Désactivé' }} {{ schedulerEnabled ? 'Actif' : 'Désactivé' }}
@ -71,7 +71,14 @@
</div> </div>
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<div class="ops-card"> <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 --> <!-- Step indicators -->
<div class="row items-center q-gutter-x-sm q-mb-sm"> <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 { BASE_URL } from 'src/config/erpnext'
import { HUB_SSE_URL } from 'src/config/dispatch' import { HUB_SSE_URL } from 'src/config/dispatch'
import { startOfWeek, localDateStr } from 'src/composables/useHelpers' import { startOfWeek, localDateStr } from 'src/composables/useHelpers'
import HelpHint from 'src/components/shared/HelpHint.vue'
import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue' import OutageAlertsPanel from 'src/components/shared/OutageAlertsPanel.vue'
import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue' import OccupancyStrip from 'src/components/shared/OccupancyStrip.vue'
import DisclosureSection from 'src/components/shared/DisclosureSection.vue' import DisclosureSection from 'src/components/shared/DisclosureSection.vue'

View File

@ -22,15 +22,64 @@
flat dense class="ops-table" flat dense class="ops-table"
:loading="loading" :loading="loading"
:pagination="{ rowsPerPage: 20 }" :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> </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> </q-page>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { listDocs } from 'src/api/erp' import { listDocs } from 'src/api/erp'
import DataTable from 'src/components/shared/DataTable.vue' 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 loading = ref(false)
const techs = ref([]) const techs = ref([])
@ -48,15 +97,17 @@ const columns = [
{ name: 'cell_number', label: 'Téléphone', field: 'cell_number', align: 'left' }, { name: 'cell_number', label: 'Téléphone', field: 'cell_number', align: 'left' },
{ name: 'office_extension', label: 'Ext.', field: 'office_extension', align: 'center' }, { name: 'office_extension', label: 'Ext.', field: 'office_extension', align: 'center' },
{ name: 'company_email', label: 'Courriel', field: 'company_email', align: 'left' }, { 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: 'status', label: 'Statut', field: 'status', align: 'center' },
{ name: 'actions', label: '', field: 'actions', align: 'right' },
] ]
onMounted(async () => { async function reload () {
loading.value = true loading.value = true
try { try {
techs.value = await listDocs('Employee', { techs.value = await listDocs('Employee', {
filters: { status: 'Active' }, 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, limit: 50,
orderBy: 'employee_name asc', orderBy: 'employee_name asc',
}) })
@ -66,5 +117,7 @@ onMounted(async () => {
techs.value = [] techs.value = []
} }
loading.value = false loading.value = false
}) }
onMounted(reload)
</script> </script>

View File

@ -29,7 +29,7 @@
</div> </div>
</q-item-section> </q-item-section>
<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-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-label v-else caption class="text-grey-5">Aucun commentaire</q-item-label>
</q-item-section> </q-item-section>

View File

@ -126,7 +126,7 @@
<template v-else-if="rep"> <template v-else-if="rep">
<div class="row q-col-gutter-md q-mb-md"> <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-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-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> <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 { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub' import { HUB_URL } from 'src/config/hub'
import HelpHint from 'src/components/shared/HelpHint.vue'
const $q = useQuasar() const $q = useQuasar()
@ -346,6 +347,10 @@ async function showDetail (field) {
reviewDetail.value = { field, items: d.sample || [], total: d.would_apply || 0 } reviewDetail.value = { field, items: d.sample || [], total: d.would_apply || 0 }
} catch (e) { $q.notify({ type: 'negative', message: e.message }) } } 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 () { function confirmApplyAll () {
const bf = rep.value.customers.review_by_field || {} const bf = rep.value.customers.review_by_field || {}
const detail = Object.entries(bf).filter(([f]) => f !== 'customer_name').map(([f, n]) => `${f}: ${n}`).join(' · ') const detail = Object.entries(bf).filter(([f]) => f !== 'customer_name').map(([f, n]) => `${f}: ${n}`).join(' · ')

View File

@ -13,6 +13,9 @@
<template #chart> <template #chart>
<div v-if="summary" class="ops-card q-mb-md" style="height:280px;position:relative"><canvas ref="chartCanvas"></canvas></div> <div v-if="summary" class="ops-card q-mb-md" style="height:280px;position:relative"><canvas ref="chartCanvas"></canvas></div>
</template> </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"> <template #body-cell-total="props">
<q-td :props="props"><span class="text-weight-bold text-negative">{{ formatMoney(props.value) }}</span></q-td> <q-td :props="props"><span class="text-weight-bold text-negative">{{ formatMoney(props.value) }}</span></q-td>
</template> </template>

View File

@ -13,23 +13,38 @@
</template> </template>
<template #body-cell-name="props"> <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>
<template #body-cell-total="props"> <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> <q-td :props="props"><span :class="props.row.is_return ? 'text-negative' : 'text-weight-bold'">{{ formatMoney(props.value) }}</span></q-td>
</template> </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"> <template #body-cell-status="props">
<q-td :props="props"><q-badge :color="statusColor(props.value)" :label="props.value" /></q-td> <q-td :props="props"><q-badge :color="statusColor(props.value)" :label="props.value" /></q-td>
</template> </template>
</ReportScaffold> </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> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { fetchSalesReport } from 'src/api/reports' 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 { exportCsv } from 'src/composables/useCsvExport'
import ReportScaffold from 'src/components/shared/ReportScaffold.vue' 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 now = new Date()
const startDate = ref(new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)) 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 }" :class="{ 'user-card--selected': selectedUser?.pk === u.pk }"
@click="selectUser(u)"> @click="selectUser(u)">
<div class="row items-center no-wrap"> <div class="row items-center no-wrap">
<q-avatar size="36px" :color="u.is_active ? 'green-1' : 'grey-3'" <UserAvatar :email="u.email" :name="u.name || u.username" :size="36" class="q-mr-sm" />
:text-color="u.is_active ? 'green-8' : 'grey-6'" class="q-mr-sm">
{{ initial(u) }}
</q-avatar>
<div class="col"> <div class="col">
<div class="text-weight-bold text-body2">{{ u.name || u.username }}</div> <div class="text-weight-bold text-body2">{{ u.name || u.username }}</div>
<div class="text-caption text-grey-6">{{ u.email }}</div> <div class="text-caption text-grey-6">{{ u.email }}</div>
@ -175,18 +173,68 @@
<!-- Selected user detail panel --> <!-- Selected user detail panel -->
<q-slide-transition> <q-slide-transition>
<div v-if="selectedUser" class="user-detail-panel q-mt-md"> <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"> <div class="row items-center q-mb-md">
<q-avatar size="42px" color="green-1" text-color="green-8" class="q-mr-md"> <!-- Avatar éditable par un admin (upload pour ce membre via ?as=). -->
{{ initial(selectedUser) }} <div class="ua-edit q-mr-md" :class="{ 'ua-edit--can': can('manage_users') }"
</q-avatar> @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="col">
<!-- Nom complet éditable (source Authentik, affichée partout). -->
<div v-if="!editingName" class="row items-center no-wrap">
<div class="text-h6">{{ selectedUser.name || selectedUser.username }}</div> <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 class="text-caption text-grey-6">{{ selectedUser.email }}</div>
<div v-if="selectedUser.last_login" class="text-caption text-grey-5"> <div v-if="selectedUser.last_login" class="text-caption text-grey-5">
Derniere connexion: {{ formatDate(selectedUser.last_login) }} Derniere connexion: {{ formatDate(selectedUser.last_login) }}
</div> </div>
</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> </div>
<!-- Groups assignment --> <!-- Groups assignment -->
@ -255,6 +303,9 @@
</q-expansion-item> </q-expansion-item>
</div> </div>
</q-slide-transition> </q-slide-transition>
<EmployeeEditDialog v-model="empDlgOpen" :employee-name="empDlgName" :link-email="empDlgLinkEmail"
@saved="onEmployeeSaved" />
</div> </div>
<!-- TAB: Groupes --> <!-- TAB: Groupes -->
@ -381,9 +432,7 @@
</div> </div>
<div v-else> <div v-else>
<div v-for="m in groupMembers" :key="m.pk" class="row items-center q-py-xs q-px-sm member-row"> <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"> <UserAvatar :email="m.email" :name="m.name || m.username" :size="28" class="q-mr-sm" />
{{ initial(m) }}
</q-avatar>
<span class="text-body2">{{ m.name || m.username }}</span> <span class="text-body2">{{ m.name || m.username }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span> <span class="text-caption text-grey-6 q-ml-sm">{{ m.email }}</span>
<q-space /> <q-space />
@ -411,9 +460,7 @@
<div v-if="memberSearchResults.length" class="member-search-dropdown"> <div v-if="memberSearchResults.length" class="member-search-dropdown">
<div v-for="u in memberSearchResults" :key="u.pk" class="member-search-item" <div v-for="u in memberSearchResults" :key="u.pk" class="member-search-item"
@click="addUserToCurrentGroup(u)"> @click="addUserToCurrentGroup(u)">
<q-avatar size="24px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:0.65rem"> <UserAvatar :email="u.email" :name="u.name || u.username" :size="24" class="q-mr-sm" />
{{ initial(u) }}
</q-avatar>
<span class="text-body2">{{ u.name || u.username }}</span> <span class="text-body2">{{ u.name || u.username }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</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" /> <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>
<div class="text-caption text-grey-6 q-mb-md"> <div class="text-caption text-grey-6 q-mb-md">
La configuration SMTP (Mailjet) se fait dans ERPNext. 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> </div>
<q-separator class="q-my-md" /> <q-separator class="q-my-md" />
@ -710,8 +757,8 @@
</q-expansion-item> </q-expansion-item>
</div> </div>
<!-- SECTION 8: Liens rapides --> <!-- SECTION 8: Liens rapides (console admin/infra ERPNext desk, n8n, Authentik) : admins seulement (manage_settings) -->
<div class="ops-card"> <div v-if="can('manage_settings')" class="ops-card">
<q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7"> <q-expansion-item default-opened header-class="section-header" expand-icon-class="text-grey-7">
<template #header> <template #header>
<SectionHeader icon="launch" label="Liens rapides" /> <SectionHeader icon="launch" label="Liens rapides" />
@ -749,6 +796,7 @@
<script setup> <script setup>
import { ref, reactive, onMounted, watch, defineAsyncComponent, h, resolveComponent } from 'vue' 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 { formatDateTimeShort as formatDate } from 'src/composables/useFormatters' // date+heure canonique (source unique)
import { Notify } from 'quasar' import { Notify } from 'quasar'
import { authFetch } from 'src/api/auth' import { authFetch } from 'src/api/auth'
@ -758,6 +806,10 @@ import { getPhoneConfig, savePhoneConfig, fetch3cxCredentials } from 'src/compos
import { usePermissions } from 'src/composables/usePermissions' import { usePermissions } from 'src/composables/usePermissions'
import { usePermissionMatrix } from 'src/composables/usePermissionMatrix' import { usePermissionMatrix } from 'src/composables/usePermissionMatrix'
import { useUserGroups } from 'src/composables/useUserGroups' 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 QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue'
import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue' import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue'
import CannedResponsesCard from 'src/components/settings/CannedResponsesCard.vue' import CannedResponsesCard from 'src/components/settings/CannedResponsesCard.vue'
@ -804,15 +856,67 @@ const {
const { const {
userSearch, userResults, userSearchLoading, userSearchDone, userSearch, userResults, userSearchLoading, userSearchDone,
selectedUser, savingGroups, selectedUser, savingGroups, savingName,
selectedGroup, groupMembers, groupMembersLoading, selectedGroup, groupMembers, groupMembersLoading,
addMemberSearch, memberSearchResults, memberSearchLoading, addMemberSearch, memberSearchResults, memberSearchLoading,
debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, debouncedSearchUsers, searchUsers, selectUser, toggleUserGroup, saveUserName,
selectGroup, loadGroupMembers, removeFromGroup, selectGroup, loadGroupMembers, removeFromGroup,
debouncedMemberSearch, addUserToCurrentGroup, debouncedMemberSearch, addUserToCurrentGroup,
inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite, inviteOpen, inviteSaving, inviteForm, lastInviteResult, openInviteDialog, submitInvite,
} = useUserGroups({ permGroups }) } = 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 () { async function copyTempPassword () {
if (!lastInviteResult.value?.temp_password) return if (!lastInviteResult.value?.temp_password) return
try { try {
@ -854,8 +958,24 @@ function notify (msg, type = 'positive', timeout = 1500) {
Notify.create({ type, message: msg, timeout }) Notify.create({ type, message: msg, timeout })
} }
function initial (u) { // Avatar d'équipe : un admin peut définir la photo d'un membre (upload via ?as=).
return (u.name || u.username || '?')[0].toUpperCase() 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) // formatDate useFormatters.formatDateTimeShort (date+heure canonique)
@ -905,14 +1025,16 @@ onMounted(async () => {
} }
if (isLoaded.value && can('manage_permissions')) { if (isLoaded.value && can('manage_permissions')) {
loadPerms() loadPerms()
searchUsers() if (route.query.user) openUserByEmail(String(route.query.user))
else searchUsers()
} }
}) })
watch(isLoaded, (loaded) => { watch(isLoaded, (loaded) => {
if (loaded && can('manage_permissions') && !permGroups.value.length) { if (loaded && can('manage_permissions') && !permGroups.value.length) {
loadPerms() loadPerms()
searchUsers() if (route.query.user) openUserByEmail(String(route.query.user))
else searchUsers()
} }
}, { immediate: false }) }, { immediate: false })
@ -973,6 +1095,11 @@ async function testSms () {
.user-card--selected { background: #eef2ff; border-left: 3px solid #6366f1; } .user-card--selected { background: #eef2ff; border-left: 3px solid #6366f1; }
.user-detail-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; } .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-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; } .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="row items-center q-mb-sm">
<div class="text-subtitle1 text-weight-bold">{{ selected.vendor || '(fournisseur à confirmer)' }}</div> <div class="text-subtitle1 text-weight-bold">{{ selected.vendor || '(fournisseur à confirmer)' }}</div>
<q-space /> <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" /> <q-btn flat round dense icon="close" @click="selected = null" />
</div> </div>

View File

@ -144,7 +144,7 @@
</template> </template>
<template #title-suffix> <template #title-suffix>
<span> &middot; <span class="text-primary">#{{ modalTicket?.legacy_ticket_id || modalTicket?.name }}</span></span> <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>
<template #header-actions> <template #header-actions>
<q-btn flat dense no-caps :icon="myFollows.includes(modalTicket?.name) ? 'star' : 'star_border'" <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": "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": "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": "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' 'use strict'
const cfg = require('./config') const cfg = require('./config')
const { log, erpFetch } = require('./helpers') 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 = {} let _convContext = {}
@ -509,4 +520,4 @@ const handleAgentApi = async (req, res, method, urlPath) => {
j(res, 404, { error: 'Agent endpoint not found' }) 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 } 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 // 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). // celui provisionné), on retrouve l'utilisateur par son username Authentik (transmis aussi par whoami).
async function findUserByUsername (username) { async function findUserByUsername (username) {
@ -68,6 +86,20 @@ async function findUserByUsername (username) {
return user ? { user } : { error: 'User not found: ' + username, status: 404 } 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) { function validatePermissions (body) {
const validKeys = new Set(CAPABILITIES.map(c => c.key)) const validKeys = new Set(CAPABILITIES.map(c => c.key))
const result = {} const result = {}
@ -117,10 +149,14 @@ async function handle (req, res, method, path, url) {
if (typeof val === 'boolean') merged[key] = val 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, { return json(res, 200, {
email: user.email, username: user.username, name: user.name, email: user.email, username: user.username, name: user.name,
groups: userGroups.map(g => g.name), is_superuser: user.is_superuser || false, 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 }) 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') { if (resource === 'users' && parts[2] === 'groups' && method === 'PUT') {
const email = decodeURIComponent(parts[1]) const email = decodeURIComponent(parts[1])
const lookup = await findUserByEmail(email) 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, parseCsv, parseMapCsv, parseGiftbitCsv,
matchCustomer, normalizeCivic, normalizePhone, normalizePostal, matchCustomer, normalizeCivic, normalizePhone, normalizePostal,
renderTemplate, renderNamedTemplate, 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 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() } 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 (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 (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 (fromEmail) msg.fromEmail = fromEmail
if (toEmail) msg.toEmail = toEmail // adresse de DESTINATION principale (mailbox reçue, ex. support@targo.ca) → affichée « à <…> » 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) 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 base = conv.lastSubject || conv.subject || ''
const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base)) const subj = withTicketTag(conv, /^re\s*:/i.test(base) ? base : ('Re: ' + base))
const outHtml = await require('./rating').expandRatingMarker(message.html || '', conv) 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.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 #…]) 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})`) 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(' ') 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 »). // 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) { async function agentSendFrom (email) {
const name = agentDisplayName(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 if (!name) return undefined
const base = require('./gmail').sendFrom() const base = require('./gmail').sendFrom()
const addr = (String(base).match(/<([^>]+)>/) || [null, base])[1].trim() 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 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() 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. // 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() 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, '') } 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 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 } 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) // réponse dans le fil si la conversation existe déjà (threadId connu)
const outHtml = await require('./rating').expandRatingMarker(html || '', conv) 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.threadId) conv.threadId = r.threadId
if (r && r.id) conv.lastGmailId = r.id if (r && r.id) conv.lastGmailId = r.id
conv.lastSubject = subject || conv.lastSubject 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(() => {}) if (conv.linkedTickets && conv.linkedTickets.length) logToLinkedTicket(conv, m, subject).catch(() => {})
conv.lastHumanDate = todayET(); saveToDisk() conv.lastHumanDate = todayET(); saveToDisk()
log(`Nouveau courriel sortant → ${dest} (gmail ${r && r.id})`) 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 agentEmail = req.headers['x-authentik-email']
const from = agentEmail ? 'agent' : 'customer' const from = agentEmail ? 'agent' : 'customer'
const isEmail = conv.channel === 'email' 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) 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 === '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 (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) 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 * 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. * 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 * Pré-requis : champ Custom Field `legacy_ticket_id` sur Dispatch Job
* (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py). * (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py).
*/ */
@ -342,8 +346,16 @@ function stripHtml (html, max = 1500) {
async function fetchTargoTickets () { async function fetchTargoTickets () {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub') 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( 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, 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, 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, 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 (SELECT mm3.msg FROM ticket_msg mm3
WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
FROM ticket t 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 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( LEFT JOIN delivery dv ON dv.id = COALESCE(
NULLIF(t.delivery_id, 0), 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), NULLIF(pt.delivery_id, 0),
(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) (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 = ? WHERE t.status = 'open' AND t.assign_to = ?
ORDER BY t.due_date DESC`, 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 // caches par run (vidés à chaque cycle) pour éviter les requêtes répétées
let _custCache = new Map() let _custCache = new Map()
let _slCache = 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) { async function resolveCustomer (accountId) {
if (!accountId) return null 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 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] 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). // 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 // 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 return out ? { ...out } : null
} }
async function buildJob (t) { async function buildJob (t, { dryRun = false } = {}) {
const cust = await resolveCustomer(t.account_id) const cust = await resolveOrCreateCustomer(t.account_id, dryRun) // crée le compte s'il manque (sauf dry-run)
const sl = cust ? await resolveServiceLocation(cust.name, t.city) : null let sl = cust ? await resolveServiceLocation(cust.name, t.city) : null
const jt = jobType(t.dept_id) const jt = jobType(t.dept_id)
const cname = cust ? cust.customer_name : ([t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || '') 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 // 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 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 billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
const addr = svcAddr || billAddr 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(' — ')) 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 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 subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
@ -513,7 +580,7 @@ async function buildJob (t) {
if (pm) { if (pm) {
const fa = [pm.dv.address1, pm.dv.city, pm.dv.zip].filter(Boolean).join(', ') 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.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 }) 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 } 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' } } 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) 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)' } 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) { async function findExisting (legacyId) {
@ -531,6 +598,44 @@ async function findExisting (legacyId) {
return (r && r[0]) || null 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 // 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 // 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. // 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. // 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). // 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). // 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() resetCaches()
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible sur le hub' } 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 }) 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é' } if (!ids.length) return { ok: true, dryRun, created: 0, note: 'aucun tech mappé' }
const now = Math.floor(Date.now() / 1000), D = 86400 const now = Math.floor(Date.now() / 1000), D = 86400
const lo = now + loDays * D, hi = now + hiDays * D 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( 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, 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, 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, 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 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 (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 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 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), 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 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 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 t.account_id > 0 AND d3.account_id = t.account_id ORDER BY d3.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 (?) AND t.due_date BETWEEN ? AND ? WHERE ${includeClosed ? '' : "t.status = 'open' AND "}t.assign_to IN (?)${allDates ? '' : ' AND t.due_date BETWEEN ? AND ?'}
ORDER BY t.due_date ASC`, [ids, lo, hi]) 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/…), // 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. // 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 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. // (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 if (!(/est\.\s?time/i.test(subj) || FIELD_RE.test(subj + ' ¦ ' + (t.dept || '')))) continue
const tech = staffToTech[t.assign_to]; if (!tech) 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' 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 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) { if (ex) {
const patch = {} 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 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) 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' }) } 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) }) } } 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) 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 = [] const details = []
for (const t of tickets) { for (const t of tickets) {
try { try {
const b = await buildJob(t) const b = await buildJob(t, { dryRun })
if (!b.matched.customer) unmatched++ if (!b.matched.customer) unmatched++
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1 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) 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 { 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 skipped++
} else if (dryRun) { } 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 { } else {
const r = await erp.create('Dispatch Job', b.payload) 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 }) } 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 }) } 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) { } catch (e) {
errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) }) errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) })
} }
} }
let closedResolved = 0 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é 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 if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat
return { ...summary, details } 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 // 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. // (sinon doublons Dispatch Job + contention ERPNext). On saute le tick tant que le précédent tourne.
let _syncInFlight = false 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`. // 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
setTimeout(tick, 90 * 1000) setTimeout(tick, 90 * 1000)
_timer = setInterval(tick, minutes * 60 * 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' }) if (!id) return json(res, 400, { ok: false, error: 'id requis' })
return json(res, 200, await ticketLookup(id)) 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 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 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/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 === '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/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 » 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 } 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 }) 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 // GET /payments/invoice/:invoice — invoice payment info for portal
const invMatch = path.match(/^\/payments\/invoice\/(.+)$/) const invMatch = path.match(/^\/payments\/invoice\/(.+)$/)
if (invMatch && method === 'GET') { 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)` } }, { 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). // 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' } } 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))` : ''}` } }
function stepsFor (scope) { return scope === 'tickets' ? [TICKET_STEP] : scope === 'all' ? [...CORE_STEPS, TICKET_STEP] : CORE_STEPS } // 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 } let _run = { running: false, scope: null, steps: [], started_at: null, finished_at: null }
async function runManual ({ confirm, scope = 'core' }) { async function runManual ({ confirm, scope = 'core' }) {

View File

@ -79,7 +79,9 @@ function geminiToMulaw (pcmB64) {
// ── Gemini Live tool definitions (different format than OpenAI) ── // ── Gemini Live tool definitions (different format than OpenAI) ──
function buildGeminiTools () { 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 [{ return [{
functionDeclarations: openaiTools functionDeclarations: openaiTools
.filter(t => t.function.name !== 'get_chat_link') // voice doesn't need chat link .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/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 '/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) '/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 '/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 // 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. // gagnent quand même car `isPublic` court-circuite ce bloc en amont.
const ALWAYS_ENFORCE = [ const ALWAYS_ENFORCE = [
'/devices', '/email-queue', '/campaigns', '/giftbit', '/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', '/network', '/sync', '/legacy-payments', '/telephony', '/flow',
'/conversations', '/dispatch', '/sla', '/events', // /events/* = vue staff RSVP (PII : noms/courriels/décompte) → token requis '/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', '/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('/auth/')) return auth.handle(req, res, method, path, url)
if (path.startsWith('/conversations')) return conversation.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 === '/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('/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('/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 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('/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('/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('/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('/dispatch')) return dispatch.handle(req, res, method, path)
if (path.startsWith('/admin/pollers')) return require('./lib/poller-control').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 // Legacy-MariaDB analytical reports — must be checked BEFORE the ERPNext