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:
parent
cfcd7c04cc
commit
14530787bb
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 :
|
||||||
|
<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' …
|
||||||
|
|
|
||||||
|
|
@ -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 à l’assistant : « ${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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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'])
|
||||||
|
|
|
||||||
155
apps/ops/src/components/shared/EmployeeEditDialog.vue
Normal file
155
apps/ops/src/components/shared/EmployeeEditDialog.vue
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
<template>
|
||||||
|
<q-dialog v-model="open" @show="onShow">
|
||||||
|
<q-card style="min-width:420px;max-width:520px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">{{ mode === 'link' ? 'Lier un profil employé' : 'Profil employé' }}</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat round dense icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section v-if="loading" class="column items-center q-py-lg">
|
||||||
|
<q-spinner size="28px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Chargement…</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- MODE LIEN : aucun employé lié à ce compte → choisir un employé existant -->
|
||||||
|
<q-card-section v-else-if="mode === 'link'">
|
||||||
|
<div class="text-caption text-grey-7 q-mb-sm">
|
||||||
|
Aucun profil employé lié à <b>{{ linkEmail }}</b>. Choisissez l'employé correspondant :
|
||||||
|
on renseignera son <code>user_id</code> pour établir le lien.
|
||||||
|
</div>
|
||||||
|
<q-select v-model="linkTarget" :options="empOptions" use-input input-debounce="300"
|
||||||
|
outlined dense label="Rechercher un employé…" option-label="employee_name" option-value="name"
|
||||||
|
@filter="filterEmployees" :loading="empSearching" clearable>
|
||||||
|
<template #option="scope">
|
||||||
|
<q-item v-bind="scope.itemProps">
|
||||||
|
<q-item-section><q-item-label>{{ scope.opt.employee_name }}</q-item-label>
|
||||||
|
<q-item-label caption>{{ scope.opt.name }}<span v-if="scope.opt.user_id"> · déjà lié à {{ scope.opt.user_id }}</span></q-item-label></q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</template>
|
||||||
|
</q-select>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- MODE ÉDITION : champs de l'employé + lien de connexion -->
|
||||||
|
<q-card-section v-else class="q-gutter-sm">
|
||||||
|
<q-input v-model="form.employee_name" label="Nom (RH)" outlined dense :disable="saving" />
|
||||||
|
<q-select v-model="form.designation" :options="designationOpts" label="Poste" outlined dense
|
||||||
|
use-input input-debounce="0" @filter="filterDesignations" clearable emit-value map-options :disable="saving" />
|
||||||
|
<q-select v-model="form.department" :options="departmentOpts" label="Département" outlined dense
|
||||||
|
use-input input-debounce="0" @filter="filterDepartments" clearable emit-value map-options :disable="saving" />
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<q-input class="col" v-model="form.cell_number" label="Téléphone" outlined dense :disable="saving" />
|
||||||
|
<q-input class="col-4" v-model="form.office_extension" label="Ext." outlined dense :disable="saving" />
|
||||||
|
</div>
|
||||||
|
<q-input v-model="form.company_email" label="Courriel (entreprise)" outlined dense :disable="saving" />
|
||||||
|
<q-input v-model="form.user_id" label="Compte de connexion lié (user_id)" outlined dense :disable="saving"
|
||||||
|
hint="Courriel du compte SSO/ERPNext — établit le lien session ↔ employé">
|
||||||
|
<template #prepend><q-icon name="link" /></template>
|
||||||
|
</q-input>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-actions align="right" class="q-pb-md q-pr-md" v-if="!loading">
|
||||||
|
<q-btn flat label="Annuler" v-close-popup :disable="saving" />
|
||||||
|
<q-btn v-if="mode === 'link'" unelevated color="primary" label="Lier" icon="link"
|
||||||
|
:loading="saving" :disable="!linkTarget" @click="doLink" />
|
||||||
|
<q-btn v-else unelevated color="primary" label="Enregistrer" icon="save" :loading="saving" @click="save" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { listDocs, getDoc, updateDoc } from 'src/api/erp'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
// Éditer un employé précis (nom du doc ERPNext). Prioritaire sur linkEmail.
|
||||||
|
employeeName: { type: String, default: '' },
|
||||||
|
// Lier : courriel du compte sans employé (mode recherche/sélection).
|
||||||
|
linkEmail: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||||
|
const open = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const mode = computed(() => props.employeeName ? 'edit' : 'link')
|
||||||
|
|
||||||
|
const EDITABLE = ['employee_name', 'designation', 'department', 'cell_number', 'office_extension', 'company_email', 'user_id']
|
||||||
|
const form = reactive({ employee_name: '', designation: null, department: null, cell_number: '', office_extension: '', company_email: '', user_id: '' })
|
||||||
|
const original = reactive({})
|
||||||
|
|
||||||
|
// Options Link (Designation/Department) chargées à l'ouverture, filtrables localement.
|
||||||
|
const allDesignations = ref([]), allDepartments = ref([])
|
||||||
|
const designationOpts = ref([]), departmentOpts = ref([])
|
||||||
|
function filterDesignations (val, update) { update(() => { const n = (val || '').toLowerCase(); designationOpts.value = allDesignations.value.filter(o => o.label.toLowerCase().includes(n)) }) }
|
||||||
|
function filterDepartments (val, update) { update(() => { const n = (val || '').toLowerCase(); departmentOpts.value = allDepartments.value.filter(o => o.label.toLowerCase().includes(n)) }) }
|
||||||
|
|
||||||
|
// Mode lien : recherche d'employés.
|
||||||
|
const linkTarget = ref(null)
|
||||||
|
const empOptions = ref([])
|
||||||
|
const empSearching = ref(false)
|
||||||
|
function filterEmployees (val, update) {
|
||||||
|
const q = (val || '').trim()
|
||||||
|
update(async () => {
|
||||||
|
empSearching.value = true
|
||||||
|
try {
|
||||||
|
empOptions.value = await listDocs('Employee', {
|
||||||
|
filters: { status: 'Active' },
|
||||||
|
or_filters: q ? [['employee_name', 'like', '%' + q + '%'], ['name', 'like', '%' + q + '%']] : undefined,
|
||||||
|
fields: ['name', 'employee_name', 'user_id'], limit: 15, orderBy: 'employee_name asc',
|
||||||
|
})
|
||||||
|
} catch { empOptions.value = [] } finally { empSearching.value = false }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onShow () {
|
||||||
|
linkTarget.value = null
|
||||||
|
if (mode.value === 'edit') {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const [doc, desigs, depts] = await Promise.all([
|
||||||
|
getDoc('Employee', props.employeeName),
|
||||||
|
listDocs('Designation', { fields: ['name'], limit: 200, orderBy: 'name asc' }),
|
||||||
|
listDocs('Department', { fields: ['name'], limit: 200, orderBy: 'name asc' }),
|
||||||
|
])
|
||||||
|
allDesignations.value = desigs.map(d => ({ label: d.name, value: d.name }))
|
||||||
|
allDepartments.value = depts.map(d => ({ label: d.name, value: d.name }))
|
||||||
|
designationOpts.value = allDesignations.value; departmentOpts.value = allDepartments.value
|
||||||
|
for (const k of EDITABLE) { form[k] = doc[k] ?? (k === 'designation' || k === 'department' ? null : ''); original[k] = form[k] }
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Chargement employé échoué: ' + e.message })
|
||||||
|
open.value = false
|
||||||
|
} finally { loading.value = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save () {
|
||||||
|
const changed = {}
|
||||||
|
for (const k of EDITABLE) if ((form[k] ?? '') !== (original[k] ?? '')) changed[k] = form[k] || ''
|
||||||
|
if (!Object.keys(changed).length) { open.value = false; return }
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await updateDoc('Employee', props.employeeName, changed)
|
||||||
|
Notify.create({ type: 'positive', message: 'Profil employé mis à jour', timeout: 1500 })
|
||||||
|
emit('saved', { name: props.employeeName, ...changed })
|
||||||
|
open.value = false
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Sauvegarde échouée: ' + e.message })
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLink () {
|
||||||
|
if (!linkTarget.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await updateDoc('Employee', linkTarget.value.name, { user_id: props.linkEmail })
|
||||||
|
Notify.create({ type: 'positive', message: `${linkTarget.value.employee_name} lié à ${props.linkEmail}`, timeout: 2000 })
|
||||||
|
emit('saved', { name: linkTarget.value.name, user_id: props.linkEmail, linked: true })
|
||||||
|
open.value = false
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Liaison échouée: ' + e.message })
|
||||||
|
} finally { saving.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
63
apps/ops/src/components/shared/HelpHint.vue
Normal file
63
apps/ops/src/components/shared/HelpHint.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<!-- Bulle d'aide contextuelle : petit ⓘ qui explique une fonction / une action.
|
||||||
|
Utilise q-tooltip → régi par le boot app-wide tooltip-ux (clic = masque, jamais 2 bulles). -->
|
||||||
|
<q-icon
|
||||||
|
:name="icon"
|
||||||
|
:size="size"
|
||||||
|
class="help-hint"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
:aria-label="aria || 'Aide'"
|
||||||
|
>
|
||||||
|
<q-tooltip
|
||||||
|
class="help-hint-tt"
|
||||||
|
:max-width="maxWidth"
|
||||||
|
anchor="top middle"
|
||||||
|
self="bottom middle"
|
||||||
|
:offset="[0, 6]"
|
||||||
|
:delay="150"
|
||||||
|
>
|
||||||
|
<div v-if="title" class="help-hint-title">{{ title }}</div>
|
||||||
|
<div class="help-hint-body"><slot>{{ text }}</slot></div>
|
||||||
|
</q-tooltip>
|
||||||
|
</q-icon>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
// Texte simple ; sinon passer du contenu riche via le slot par défaut.
|
||||||
|
text: { type: String, default: '' },
|
||||||
|
// Titre optionnel en gras au-dessus du texte.
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
icon: { type: String, default: 'info_outline' },
|
||||||
|
size: { type: String, default: '15px' },
|
||||||
|
maxWidth: { type: String, default: '280px' },
|
||||||
|
aria: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.help-hint {
|
||||||
|
color: var(--ops-muted, #94a3b8);
|
||||||
|
cursor: help;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: color 0.12s ease;
|
||||||
|
}
|
||||||
|
.help-hint:hover,
|
||||||
|
.help-hint:focus-visible {
|
||||||
|
color: var(--ops-accent, #0c8f9e);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.help-hint-tt {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 8px 11px;
|
||||||
|
}
|
||||||
|
.help-hint-title {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.help-hint-body :deep(b) { font-weight: 700; }
|
||||||
|
.help-hint-body :deep(ul) { margin: 4px 0 0; padding-left: 16px; }
|
||||||
|
.help-hint-body :deep(li) { margin: 2px 0; }
|
||||||
|
</style>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }]
|
||||||
|
|
|
||||||
|
|
@ -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">⏰ Reporter</q-item-label>
|
<q-item-label header class="q-py-xs">⏰ Reporter <HelpHint title="Reporter — mise en attente" text="Ce bouton reporte (snooze) ou ferme le billet ; ce n'est PAS le statut. Le statut se change dans le sélecteur du panneau de détail. « Reporter » repousse la date ; « En attente d'un autre ticket » met en pause jusqu'à la résolution d'un autre billet." /></q-item-label>
|
||||||
<q-item clickable v-close-popup @click="postpone(1)"><q-item-section avatar><q-icon name="snooze" color="indigo" /></q-item-section><q-item-section>À demain (+1 jour)</q-item-section></q-item>
|
<q-item clickable v-close-popup @click="postpone(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'
|
||||||
|
|
|
||||||
53
apps/ops/src/components/shared/UserAvatar.vue
Normal file
53
apps/ops/src/components/shared/UserAvatar.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<template>
|
||||||
|
<!-- Avatar CANONIQUE d'un membre : photo si présente, sinon repli initiales +
|
||||||
|
couleur déterministe (useFormatters). Réutiliser PARTOUT plutôt que de
|
||||||
|
recoder q-avatar + initial() dans chaque page. -->
|
||||||
|
<q-avatar :size="size + 'px'" :class="['user-avatar', { 'user-avatar--img': showImg }]"
|
||||||
|
:style="showImg ? null : { background: bg, color: '#fff', fontSize: Math.round(size * 0.42) + 'px' }">
|
||||||
|
<img v-if="showImg" :src="src" :alt="name || email" referrerpolicy="no-referrer" @error="failed = true">
|
||||||
|
<template v-else>{{ ini }}</template>
|
||||||
|
<q-tooltip v-if="tooltip && (name || email)">{{ name || email }}</q-tooltip>
|
||||||
|
<slot />
|
||||||
|
</q-avatar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { staffColor, staffInitials } from 'src/composables/useFormatters'
|
||||||
|
import { avatarVersion, hasAvatar, ensureAvatarIndex } from 'src/composables/useAvatar'
|
||||||
|
import { HUB_URL } from 'src/config/hub'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
email: { type: String, default: '' },
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
size: { type: Number, default: 28 },
|
||||||
|
tooltip: { type: Boolean, default: false },
|
||||||
|
// Change de valeur après un upload → force le rechargement de l'image (cache-bust).
|
||||||
|
version: { type: [Number, String], default: '' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = ref(false)
|
||||||
|
const src = computed(() => {
|
||||||
|
if (!props.email) return ''
|
||||||
|
// Version explicite (prop) sinon version partagée (bump après upload) → cache-bust.
|
||||||
|
const ver = props.version || avatarVersion(props.email)
|
||||||
|
const v = ver ? ('?v=' + ver) : ''
|
||||||
|
return `${HUB_URL}/avatars/${encodeURIComponent(props.email.toLowerCase())}${v}`
|
||||||
|
})
|
||||||
|
// On ne tente le <img> QUE si l'index indique une photo pour ce courriel (évite un
|
||||||
|
// 404 par membre sans photo) ; sinon initiales directement. @error → repli aussi.
|
||||||
|
const showImg = computed(() => !!props.email && hasAvatar(props.email) && !failed.value)
|
||||||
|
const bg = computed(() => staffColor(props.email || props.name))
|
||||||
|
const ini = computed(() => staffInitials(props.name) || (props.email ? props.email[0].toUpperCase() : '?'))
|
||||||
|
|
||||||
|
// Réinitialise l'état d'échec quand la cible change (email/version).
|
||||||
|
watch(src, () => { failed.value = false })
|
||||||
|
|
||||||
|
// Charge l'index d'avatars une fois (partagé/idempotent).
|
||||||
|
onMounted(() => { ensureAvatarIndex() })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-avatar { font-weight: 600; }
|
||||||
|
.user-avatar--img :deep(img) { object-fit: cover; }
|
||||||
|
</style>
|
||||||
128
apps/ops/src/components/shared/UserProfileDialog.vue
Normal file
128
apps/ops/src/components/shared/UserProfileDialog.vue
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<template>
|
||||||
|
<q-dialog v-model="open">
|
||||||
|
<q-card style="min-width:360px;max-width:440px">
|
||||||
|
<q-card-section class="row items-center q-pb-none">
|
||||||
|
<div class="text-h6">Mon profil</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn flat round dense icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section class="column items-center q-pt-md">
|
||||||
|
<!-- Photo actuelle (grande) -->
|
||||||
|
<UserAvatar :email="email" :name="nameDraft || name" :size="96" class="q-mb-sm" />
|
||||||
|
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
class="hidden" @change="onFile">
|
||||||
|
<div class="row q-gutter-sm q-mb-md">
|
||||||
|
<q-btn dense no-caps unelevated color="primary" icon="photo_camera"
|
||||||
|
:loading="uploading" label="Changer la photo" @click="pickFile" />
|
||||||
|
<q-btn dense no-caps flat color="grey-7" icon="delete" label="Retirer"
|
||||||
|
:disable="uploading" @click="removePhoto" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nom complet éditable -->
|
||||||
|
<q-input v-model="nameDraft" label="Nom complet" outlined dense class="full-width q-mb-sm"
|
||||||
|
:disable="savingName" @keyup.enter="commitName">
|
||||||
|
<template #append>
|
||||||
|
<q-btn v-if="nameDraft.trim() && nameDraft.trim() !== name" flat dense round
|
||||||
|
icon="check" color="primary" :loading="savingName" @click="commitName">
|
||||||
|
<q-tooltip>Enregistrer le nom</q-tooltip>
|
||||||
|
</q-btn>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<div class="full-width text-caption text-grey-6 q-mb-xs">{{ email }}</div>
|
||||||
|
<div v-if="groups.length" class="row q-gutter-xs full-width q-mb-sm">
|
||||||
|
<q-badge v-for="g in groups" :key="g" color="green-1" text-color="green-8">{{ g }}</q-badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profil employé lié (lecture seule ici ; édition dans Équipe/Administration) -->
|
||||||
|
<div class="full-width q-pa-sm q-mt-xs" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px">
|
||||||
|
<div v-if="employee" class="row items-center no-wrap">
|
||||||
|
<q-icon name="badge" size="18px" color="green-7" class="q-mr-sm" />
|
||||||
|
<div class="col">
|
||||||
|
<div class="text-body2">{{ employee.employee_name }}</div>
|
||||||
|
<div class="text-caption text-grey-6">{{ [employee.designation, employee.department].filter(Boolean).join(' · ') || 'Profil employé lié' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-caption text-grey-6">
|
||||||
|
<q-icon name="link_off" size="14px" /> Aucun profil employé lié à votre compte — un administrateur peut le lier (Équipe / Administration).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mt-md">
|
||||||
|
La photo (ou vos initiales) apparaît partout où votre nom est affiché : fils de discussion, lecteurs, équipe.
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import UserAvatar from './UserAvatar.vue'
|
||||||
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
|
import { useAvatar } from 'src/composables/useAvatar'
|
||||||
|
|
||||||
|
const props = defineProps({ modelValue: { type: Boolean, default: false } })
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const open = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
|
||||||
|
|
||||||
|
const { userEmail, userName, permissions, HUB_URL } = usePermissions()
|
||||||
|
const { uploadAvatar, deleteAvatar, fileToAvatarDataUrl } = useAvatar()
|
||||||
|
|
||||||
|
const email = computed(() => userEmail.value)
|
||||||
|
const name = computed(() => userName.value)
|
||||||
|
const groups = computed(() => permissions.value?.groups || [])
|
||||||
|
const employee = computed(() => permissions.value?.employee || null)
|
||||||
|
|
||||||
|
const fileInput = ref(null)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const savingName = ref(false)
|
||||||
|
const nameDraft = ref('')
|
||||||
|
|
||||||
|
// (Ré)initialise le brouillon de nom à l'ouverture.
|
||||||
|
watch(open, (v) => { if (v) nameDraft.value = name.value || '' })
|
||||||
|
|
||||||
|
function pickFile () { fileInput.value?.click() }
|
||||||
|
|
||||||
|
async function onFile (e) {
|
||||||
|
const file = e.target.files && e.target.files[0]
|
||||||
|
e.target.value = '' // permet de re-choisir le même fichier
|
||||||
|
if (!file) return
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const dataUrl = await fileToAvatarDataUrl(file)
|
||||||
|
await uploadAvatar(dataUrl)
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Image illisible: ' + err.message })
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePhoto () {
|
||||||
|
uploading.value = true
|
||||||
|
try { await deleteAvatar() } finally { uploading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitName () {
|
||||||
|
const n = nameDraft.value.trim()
|
||||||
|
if (!n || n === name.value) return
|
||||||
|
savingName.value = true
|
||||||
|
try {
|
||||||
|
const r = await fetch(HUB_URL + '/auth/users/' + encodeURIComponent(email.value) + '/name', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: n }),
|
||||||
|
})
|
||||||
|
const data = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok) throw new Error(data.error || ('HTTP ' + r.status))
|
||||||
|
if (permissions.value) permissions.value.name = n // reflète immédiatement dans l'app
|
||||||
|
Notify.create({ type: 'positive', message: 'Nom mis à jour', timeout: 1500 })
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Erreur: ' + err.message })
|
||||||
|
} finally {
|
||||||
|
savingName.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
<template>
|
<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()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
<!--
|
||||||
|
DurationField — durée estimée d'un job (persiste duration_h via roster.updateJob).
|
||||||
|
Module RÉUTILISABLE extrait du volet jobDetail. Autonome : écrit directement au hub, émet `updated` (le board
|
||||||
|
peut alors rafraîchir son override d'optimisation / ré-optimiser dans son handler ; le détail ticket ignore).
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="jd-dur row items-center q-gutter-sm q-my-sm">
|
||||||
|
<q-icon name="timer" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Durée estimée</span>
|
||||||
|
<q-input dense outlined type="number" step="0.25" min="0" v-model.number="local" style="width:96px" suffix="h"
|
||||||
|
:loading="busy" @change="save" @keyup.enter="save" />
|
||||||
|
<span class="text-caption text-grey-5">≈ {{ fmtMin(Math.round((local || 0) * 60)) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import * as roster from 'src/api/roster'
|
||||||
|
|
||||||
|
// Durée lisible (source unique locale — fmtMin n'est pas dans useFormatters).
|
||||||
|
const fmtMin = (m) => { m = Math.round(Number(m) || 0); const h = Math.floor(m / 60), mm = m % 60; return h ? (h + 'h' + (mm ? String(mm).padStart(2, '0') : '')) : (mm + 'min') }
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||||
|
durH: { type: Number, default: 1 },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['updated'])
|
||||||
|
|
||||||
|
const local = ref(props.durH)
|
||||||
|
const busy = ref(false)
|
||||||
|
watch(() => props.durH, v => { local.value = v })
|
||||||
|
|
||||||
|
async function save () {
|
||||||
|
const h = Math.round((Number(local.value) || 0) * 100) / 100
|
||||||
|
if (h <= 0 || !props.name) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await roster.updateJob(props.name, { duration_h: h })
|
||||||
|
emit('updated', { duration_h: h }) // le board persiste son override / ré-optimise ; le ticket ignore
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Durée non enregistrée : ' + (e && e.message), timeout: 3000 })
|
||||||
|
} finally { busy.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
<!--
|
||||||
|
GeofenceTimeline — suivi terrain (géofencing GPS) façon suivi de colis : Assigné → En route → Arrivé → Reparti.
|
||||||
|
Module RÉUTILISABLE extrait du volet jobDetail (PlanificationPage). Autonome : il charge lui-même la timeline
|
||||||
|
via roster.jobGeofence(name) si aucun objet `geofence` n'est fourni. Aucune dépendance à l'état de la page.
|
||||||
|
N'affiche rien si le job n'a pas d'état de géofence (pas de bruit).
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div v-if="gf && gf.state" class="jd-geo">
|
||||||
|
<div class="text-subtitle2 row items-center q-mb-xs"><q-icon name="my_location" size="18px" class="q-mr-xs" color="teal-7" /> Suivi terrain (GPS)</div>
|
||||||
|
<div class="jd-geo-track">
|
||||||
|
<div v-for="s in timeline" :key="s.k" class="jd-geo-step" :class="{ done: s.done, current: s.current }">
|
||||||
|
<div class="jd-geo-ic"><q-icon :name="s.icon" size="15px" /></div>
|
||||||
|
<div class="jd-geo-lbl">{{ s.label }}<span v-if="s.at" class="jd-geo-at">{{ s.at }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import * as roster from 'src/api/roster'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||||
|
geofence: { type: Object, default: null }, // objet déjà chargé { state, events } — sinon on le fetch
|
||||||
|
})
|
||||||
|
|
||||||
|
const GEO_STEPS = [
|
||||||
|
{ k: 'assigned', label: 'Assigné', icon: 'assignment_ind' },
|
||||||
|
{ k: 'en_route', label: 'En route', icon: 'directions_car' },
|
||||||
|
{ k: 'on_site', label: 'Arrivé sur place', icon: 'location_on' },
|
||||||
|
{ k: 'departed', label: 'Reparti', icon: 'check_circle' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const loaded = ref(null)
|
||||||
|
const gf = computed(() => props.geofence || loaded.value)
|
||||||
|
|
||||||
|
const timeline = computed(() => {
|
||||||
|
const g = gf.value; const events = (g && g.events) || []
|
||||||
|
const atOf = (k) => { const e = [...events].reverse().find(x => x.status === k); return e ? new Date(e.at).toLocaleTimeString('fr-CA', { hour: '2-digit', minute: '2-digit', timeZone: 'America/Toronto' }) : null }
|
||||||
|
const order = { assigned: 0, en_route: 1, on_site: 2, departed: 3 }
|
||||||
|
const curIdx = order[(g && g.state) || 'assigned'] ?? 0
|
||||||
|
return GEO_STEPS.map((s, i) => ({ ...s, at: s.k === 'assigned' ? null : atOf(s.k), done: i <= curIdx, current: i === curIdx }))
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load () {
|
||||||
|
if (props.geofence || !props.name) return
|
||||||
|
try { loaded.value = await roster.jobGeofence(props.name) } catch (e) { loaded.value = null }
|
||||||
|
}
|
||||||
|
watch(() => props.name, () => { loaded.value = null; load() })
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jd-geo { margin-top: 12px; padding: 10px 8px; border: 1px solid #d6eeeb; background: #f3fbfa; border-radius: 8px; }
|
||||||
|
.jd-geo-track { display: flex; align-items: flex-start; }
|
||||||
|
.jd-geo-step { flex: 1; text-align: center; position: relative; }
|
||||||
|
.jd-geo-step::before { content: ''; position: absolute; top: 13px; left: -50%; width: 100%; height: 2px; background: #cfe3e0; z-index: 0; }
|
||||||
|
.jd-geo-step:first-child::before { display: none; }
|
||||||
|
.jd-geo-step.done::before { background: #26a69a; }
|
||||||
|
.jd-geo-ic { position: relative; z-index: 1; width: 28px; height: 28px; margin: 0 auto; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: #e0e0e0; color: #fff; }
|
||||||
|
.jd-geo-step.done .jd-geo-ic { background: #26a69a; }
|
||||||
|
.jd-geo-step.current .jd-geo-ic { box-shadow: 0 0 0 3px rgba(38,166,154,.3); }
|
||||||
|
.jd-geo-lbl { font-size: 10.5px; color: #607d8b; margin-top: 3px; line-height: 1.25; }
|
||||||
|
.jd-geo-step.done .jd-geo-lbl { color: #37474f; font-weight: 600; }
|
||||||
|
.jd-geo-at { display: block; font-size: 10px; color: #26a69a; font-weight: 700; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
<!--
|
||||||
|
JobMapModule — carte du job (repère) + tracé GPS réel (Traccar) + liens géo externes (Google Maps / Street View).
|
||||||
|
Module RÉUTILISABLE extrait du volet jobDetail (PlanificationPage). Autonome : MapLibre/OSM auto-hébergé via createPlanMap,
|
||||||
|
tracé via roster.traccarTrack. Carte repliée par défaut (non requise pour lire le ticket) — dépliable au besoin.
|
||||||
|
Les points de contexte « à proximité » sont OPTIONNELS (prop `nearby`) : le board les fournit, le détail ticket non.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="q-mb-sm">
|
||||||
|
<div v-if="hasCoords" class="jd-meta q-mb-xs">
|
||||||
|
<div class="jd-geolinks">
|
||||||
|
<a :href="mapsUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="satellite_alt" size="14px" /> Carte / satellite</a>
|
||||||
|
<a :href="streetViewUrl" target="_blank" rel="noopener" class="jd-geolink"><q-icon name="streetview" size="14px" /> Street View</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-btn flat dense no-caps size="sm" color="grey-7" :icon="show ? 'expand_less' : 'map'"
|
||||||
|
:label="show ? 'Masquer la carte' : 'Voir la carte / tracé GPS'" @click="show = !show" />
|
||||||
|
<div v-show="show">
|
||||||
|
<div ref="mapEl" class="jd-map"></div>
|
||||||
|
<div v-if="canTrack" class="jd-track-row">
|
||||||
|
<q-toggle v-model="showTrack" dense size="sm" color="warning" />
|
||||||
|
<span class="text-caption text-weight-medium">Tracé GPS réel (Traccar)</span>
|
||||||
|
<span class="text-caption text-grey-6">{{ trackMsg }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-caption text-grey-5 q-mb-sm">Tracé GPS réel : aujourd'hui / hier seulement (Traccar).</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { createPlanMap } from 'src/config/basemap'
|
||||||
|
import * as roster from 'src/api/roster'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' }, // docname (non utilisé pour le rendu ; réservé)
|
||||||
|
lat: { type: [Number, String], default: null },
|
||||||
|
lon: { type: [Number, String], default: null },
|
||||||
|
techId: { type: String, default: '' }, // pour le tracé GPS
|
||||||
|
iso: { type: String, default: '' }, // jour du tracé (YYYY-MM-DD)
|
||||||
|
canTrack: { type: Boolean, default: false }, // Traccar dispo (aujourd'hui/hier seulement)
|
||||||
|
todayIso: { type: String, default: '' }, // pour borner le tracé au « maintenant » si c'est aujourd'hui
|
||||||
|
nearby: { type: Array, default: () => [] }, // points pâles de contexte : [[lon, lat, subject], …]
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapEl = ref(null); let _map = null
|
||||||
|
const show = ref(false)
|
||||||
|
const showTrack = ref(false); const trackMsg = ref('')
|
||||||
|
|
||||||
|
const laNum = computed(() => +props.lat); const loNum = computed(() => +props.lon)
|
||||||
|
const hasCoords = computed(() => isFinite(laNum.value) && isFinite(loNum.value) && (laNum.value !== 0 || loNum.value !== 0))
|
||||||
|
const mapsUrl = computed(() => `https://www.google.com/maps/search/?api=1&query=${props.lat},${props.lon}`)
|
||||||
|
const streetViewUrl = computed(() => `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${props.lat},${props.lon}`)
|
||||||
|
|
||||||
|
async function initMap () {
|
||||||
|
if (!mapEl.value) return
|
||||||
|
if (_map) { try { _map.remove() } catch (e) {} _map = null }
|
||||||
|
const hasLL = hasCoords.value
|
||||||
|
const mapboxgl = window.mapboxgl // installé par createPlanMap
|
||||||
|
_map = createPlanMap(mapEl.value, { center: hasLL ? [loNum.value, laNum.value] : [-73.6756, 45.1599], zoom: hasLL ? 14 : 9 })
|
||||||
|
_map.on('load', () => {
|
||||||
|
_map.resize()
|
||||||
|
if (hasLL && props.nearby.length) {
|
||||||
|
_map.addSource('jd-near', { type: 'geojson', data: { type: 'FeatureCollection', features: props.nearby.map(p => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p[0], p[1]] }, properties: { t: p[2] } })) } })
|
||||||
|
_map.addLayer({ id: 'jd-near', type: 'circle', source: 'jd-near', paint: { 'circle-radius': 5, 'circle-color': '#94a3b8', 'circle-opacity': 0.45, 'circle-stroke-color': '#fff', 'circle-stroke-width': 1 } })
|
||||||
|
_map.on('mouseenter', 'jd-near', () => { _map.getCanvas().style.cursor = 'pointer' })
|
||||||
|
_map.on('mouseleave', 'jd-near', () => { _map.getCanvas().style.cursor = '' })
|
||||||
|
_map.on('click', 'jd-near', (e) => { const p = e.features[0].properties; new mapboxgl.Popup({ offset: 10 }).setLngLat(e.features[0].geometry.coordinates).setHTML('<div style="font-size:11px;color:#475569">' + String(p.t || '').replace(/[<>&]/g, '') + '</div>').addTo(_map) })
|
||||||
|
}
|
||||||
|
if (hasLL) new mapboxgl.Marker({ color: '#1976d2' }).setLngLat([loNum.value, laNum.value]).addTo(_map)
|
||||||
|
_map.addSource('jd-track', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||||
|
_map.addLayer({ id: 'jd-track-halo', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ff6f00', 'line-width': 8, 'line-opacity': 0.22 } })
|
||||||
|
_map.addLayer({ id: 'jd-track-l', type: 'line', source: 'jd-track', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': '#ef6c00', 'line-width': 3, 'line-opacity': 0.78 } })
|
||||||
|
if (showTrack.value) loadTrack()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async function loadTrack () {
|
||||||
|
trackMsg.value = '…'
|
||||||
|
const iso = props.iso; if (!iso || !props.techId || !_map) { trackMsg.value = ''; return }
|
||||||
|
const from = new Date(iso + 'T00:00:00').toISOString()
|
||||||
|
const to = (props.todayIso && iso === props.todayIso) ? new Date().toISOString() : new Date(iso + 'T23:59:59').toISOString()
|
||||||
|
try {
|
||||||
|
const r = await roster.traccarTrack(props.techId, from, to); const coords = (r && r.coords) || []
|
||||||
|
const src = _map.getSource('jd-track')
|
||||||
|
if (src) src.setData(coords.length >= 2 ? { type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} } : { type: 'FeatureCollection', features: [] })
|
||||||
|
trackMsg.value = coords.length >= 2 ? (coords.length + ' points GPS') : "Aucun tracé ce jour (ou pas d'appareil)."
|
||||||
|
} catch (e) { trackMsg.value = 'Tracé indisponible' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy () { if (_map) { try { _map.remove() } catch (e) {} _map = null } }
|
||||||
|
watch(show, (on) => { if (on) nextTick(() => setTimeout(initMap, 200)); else destroy() })
|
||||||
|
watch(showTrack, (on) => { if (!_map) return; if (on) loadTrack(); else { const s = _map.getSource('jd-track'); if (s) s.setData({ type: 'FeatureCollection', features: [] }); trackMsg.value = '' } })
|
||||||
|
onBeforeUnmount(destroy)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.jd-meta { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #455; }
|
||||||
|
.jd-geolinks { display: flex; padding-left: 22px; gap: 14px; }
|
||||||
|
.jd-geolink { display: inline-flex; align-items: center; gap: 3px; font-size: 12px; color: #2563eb; text-decoration: none; font-weight: 500; }
|
||||||
|
.jd-geolink:hover { text-decoration: underline; }
|
||||||
|
.jd-map { width: 100%; height: 240px; border-radius: 8px; overflow: hidden; margin-bottom: 8px; background: #eceff3; }
|
||||||
|
.jd-track-row { display: flex; align-items: center; gap: 6px; margin: -2px 0 8px; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<!--
|
||||||
|
JobReplyBox — répondre au fil d'un job (note INTERNE par défaut ; au client si coché, quand un billet legacy est lié).
|
||||||
|
Module RÉUTILISABLE extrait du volet jobDetail. Poste via roster.postJobComment ; émet `sent` pour que le parent
|
||||||
|
recharge le fil (ex. JobThread.reload()). Attribution = agent connecté (prénom+nom) via usePermissions.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div v-if="lid || name" class="q-mt-sm">
|
||||||
|
<q-input v-model="reply" dense outlined type="textarea" autogrow placeholder="Écrire une note / réponse…"
|
||||||
|
:input-style="{ minHeight: '48px' }" @keydown.ctrl.enter="send" @keydown.meta.enter="send" />
|
||||||
|
<div class="row items-center q-mt-xs">
|
||||||
|
<q-checkbox v-if="lid" v-model="replyPublic" dense size="sm" label="Envoyer aussi au client" />
|
||||||
|
<q-space />
|
||||||
|
<q-btn dense unelevated color="primary" no-caps icon="send"
|
||||||
|
:label="replyPublic ? 'Répondre au client' : 'Note interne'"
|
||||||
|
:disable="!reply.trim()" :loading="sending" @click="send" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import * as roster from 'src/api/roster'
|
||||||
|
import { usePermissions } from 'src/composables/usePermissions'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||||
|
lid: { type: [String, Number], default: null }, // n° ticket legacy (permet « au client »)
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['sent'])
|
||||||
|
|
||||||
|
const { userName } = usePermissions()
|
||||||
|
const reply = ref('')
|
||||||
|
const replyPublic = ref(false)
|
||||||
|
const sending = ref(false)
|
||||||
|
|
||||||
|
async function send () {
|
||||||
|
const text = reply.value.trim(); if (!text || sending.value) return
|
||||||
|
sending.value = true
|
||||||
|
try {
|
||||||
|
const r = await roster.postJobComment({ job: props.name, lid: props.lid, text, isPublic: replyPublic.value, agentName: userName.value || '' })
|
||||||
|
if (r && r.ok) {
|
||||||
|
const wasPublic = replyPublic.value
|
||||||
|
reply.value = ''; replyPublic.value = false
|
||||||
|
Notify.create({ type: 'positive', message: wasPublic ? 'Réponse envoyée au client' : 'Note interne ajoutée', timeout: 2200 })
|
||||||
|
emit('sent', { public: wasPublic })
|
||||||
|
} else { Notify.create({ type: 'negative', message: (r && r.error) || "Échec de l'envoi", timeout: 3000 }) }
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: "Échec de l'envoi", timeout: 3000 }) } finally { sending.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
<!--
|
||||||
|
JobTeamField — équipe d'un Dispatch Job (assigné + assistants/renfort + suiveurs) via le champ partagé AssignmentField.
|
||||||
|
Module RÉUTILISABLE extrait du volet jobDetail. Il ENCAPSULE la gestion des assistants (roster.addAssistant /
|
||||||
|
removeAssistant / getJobTeam) et émet `updated` après chaque changement (le parent rafraîchit l'occupation).
|
||||||
|
L'assignation du LEAD reste au parent (émise via `assign-lead` / `unassign-lead`) : le board utilise sa pipeline
|
||||||
|
assignNames (groupes / géorepérage), le détail ticket un chemin simple. Slot #right passé à AssignmentField (ex. statut).
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<AssignmentField
|
||||||
|
doctype="Dispatch Job" :doc-name="name"
|
||||||
|
:assignee="assignee" :assistants="assistants"
|
||||||
|
:tech-options="techOptions" :can-onsite="canOnsite" :assist-loading="teamLoading || busy"
|
||||||
|
@assign="v => $emit('assign-lead', v)" @unassign="$emit('unassign-lead')"
|
||||||
|
@set-assistant="onSetAssistant" @remove-assistant="onRemoveAssistant">
|
||||||
|
<template #right><slot name="right" /></template>
|
||||||
|
</AssignmentField>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import * as roster from 'src/api/roster'
|
||||||
|
import AssignmentField from 'src/components/shared/AssignmentField.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||||
|
techId: { type: String, default: '' }, // lead assigné (id)
|
||||||
|
techName: { type: String, default: '' }, // lead assigné (nom)
|
||||||
|
team: { type: Array, default: null }, // assistants [{ tech_id, tech_name, pinned }] — si null, le module charge lui-même
|
||||||
|
techOptions: { type: Array, default: () => [] }, // suggestions (techs + compétences)
|
||||||
|
canOnsite: { type: Boolean, default: true }, // niveau « Sur place » (bloc horaire) dispo ?
|
||||||
|
teamLoading: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['assign-lead', 'unassign-lead', 'updated'])
|
||||||
|
|
||||||
|
const localTeam = ref(props.team || [])
|
||||||
|
const busy = ref(false)
|
||||||
|
watch(() => props.team, v => { if (v) localTeam.value = v })
|
||||||
|
|
||||||
|
const assignee = computed(() => (props.techName || props.techId) ? { id: props.techId, name: props.techName } : null)
|
||||||
|
// Nom d'assistant robuste : une vieille ligne peut porter tech_name = « undefined »/« null » → repli sur l'id.
|
||||||
|
function assistantName (a) { const bad = v => !v || v === 'undefined' || v === 'null'; if (a && !bad(a.tech_name)) return a.tech_name; return (a && !bad(a.tech_id)) ? a.tech_id : '—' }
|
||||||
|
// onsite = pinned (bloc horaire réservé). Assistant simple (pinned 0) = sur l'équipe sans bloc.
|
||||||
|
const assistants = computed(() => (localTeam.value || []).map(a => ({ id: a.tech_id, name: assistantName(a), onsite: !!a.pinned })))
|
||||||
|
|
||||||
|
async function reloadTeam () {
|
||||||
|
if (!props.name) return
|
||||||
|
try { const r = await roster.getJobTeam(props.name); localTeam.value = r.assistants || [] } catch (e) { /* garde l'existant */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter / mettre à jour un assistant : { value=id, label, onsite }. Le hub `add` remplace la ligne du même tech_id
|
||||||
|
// → sert aussi à CHANGER le niveau (assistant ↔ sur place). Nom robuste depuis le libellé de l'option.
|
||||||
|
async function onSetAssistant ({ value, label, onsite } = {}) {
|
||||||
|
const id = value; if (!id || !props.name) return
|
||||||
|
const name = (label && String(label).split(' · ')[0].trim()) || String(id)
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await roster.addAssistant(props.name, { tech_id: String(id), tech_name: name, duration_h: 0, pinned: onsite ? 1 : 0 })
|
||||||
|
await reloadTeam()
|
||||||
|
emit('updated', localTeam.value)
|
||||||
|
Notify.create({ type: 'positive', icon: onsite ? 'construction' : 'group_add', message: name + (onsite ? ' — sur place (bloc réservé)' : ' — assistant'), timeout: 2400 })
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur équipe : ' + (e && e.message), timeout: 3000 }) } finally { busy.value = false }
|
||||||
|
}
|
||||||
|
async function onRemoveAssistant (techId) {
|
||||||
|
if (!props.name) return
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await roster.removeAssistant(props.name, techId); await reloadTeam(); emit('updated', localTeam.value)
|
||||||
|
Notify.create({ type: 'info', message: 'Assistant retiré', timeout: 1600 })
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur : ' + (e && e.message), timeout: 3000 }) } finally { busy.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { if (props.team == null && props.name) reloadTeam() })
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<!--
|
||||||
|
JobThread — fil du billet (osTicket legacy) d'un job, PLUS RÉCENT EN HAUT. Module RÉUTILISABLE extrait du volet jobDetail.
|
||||||
|
Autonome : charge via roster.ticketThread(lid) si aucun `thread` n'est fourni. Expose reload() (le parent l'appelle
|
||||||
|
après un envoi via JobReplyBox). Repli sur le corps brut si le fil est vide.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle2 q-mt-md q-mb-xs row items-center">
|
||||||
|
Commentaires / fil du billet
|
||||||
|
<span v-if="messages.length" class="text-grey-5 q-ml-sm" style="font-size:10px">· plus récent en haut</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="loading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /><div class="text-caption text-grey-6 q-mt-sm">Lecture du ticket…</div></div>
|
||||||
|
<template v-else-if="messages.length">
|
||||||
|
<div class="text-grey-6 q-mb-xs" style="font-size:10px">{{ messages.length }} message(s){{ threadStatus ? ' · ' + threadStatus : '' }}</div>
|
||||||
|
<div v-for="(m, mi) in messages" :key="mi" class="de-msg" :class="{ 'de-msg-latest': mi === 0 }">
|
||||||
|
<div class="de-msg-hdr">{{ m.author }}<span v-if="m.at" class="text-grey-5"> · {{ fmtDT(m.at) }}</span><span v-if="mi === 0 && messages.length > 1" class="de-msg-badge">dernier</span></div>
|
||||||
|
<div class="de-msg-txt">{{ m.text }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else-if="detail" class="jd-detail">{{ detail }}</div>
|
||||||
|
<div v-else class="text-grey-6 q-pa-md text-center">{{ lid ? (loadErr ? 'Détail indisponible' : 'Aucun commentaire.') : 'Pas de billet Legacy lié à ce job.' }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
|
import * as roster from 'src/api/roster'
|
||||||
|
import { formatDateTime as fmtDT } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
lid: { type: [String, Number], default: null }, // n° ticket legacy (osTicket)
|
||||||
|
thread: { type: Object, default: null }, // { messages, status } déjà chargé — sinon on fetch
|
||||||
|
detail: { type: String, default: '' }, // corps brut de repli (si aucun message)
|
||||||
|
})
|
||||||
|
|
||||||
|
const loaded = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadErr = ref(false)
|
||||||
|
const activeThread = computed(() => props.thread || loaded.value)
|
||||||
|
const threadStatus = computed(() => (activeThread.value && activeThread.value.status) || '')
|
||||||
|
// Fil PLUS RÉCENT EN HAUT (le dernier commentaire porte souvent l'info la plus importante).
|
||||||
|
const messages = computed(() => { const m = (activeThread.value && activeThread.value.messages) || []; return m.slice().reverse() })
|
||||||
|
|
||||||
|
async function reload () {
|
||||||
|
if (props.thread || !props.lid) return
|
||||||
|
loading.value = true; loadErr.value = false
|
||||||
|
try { loaded.value = await roster.ticketThread(props.lid) } catch (e) { loaded.value = { error: true, messages: [] }; loadErr.value = true } finally { loading.value = false }
|
||||||
|
}
|
||||||
|
watch(() => props.lid, () => { loaded.value = null; reload() })
|
||||||
|
onMounted(reload)
|
||||||
|
defineExpose({ reload })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.de-msg { border-top: 1px solid #e6e0f0; padding: 4px 0; }
|
||||||
|
.de-msg:first-of-type { border-top: none; }
|
||||||
|
.de-msg-hdr { font-size: 11px; font-weight: 700; color: #5e35b1; }
|
||||||
|
.de-msg-txt { font-size: 11px; white-space: pre-wrap; color: #37474f; }
|
||||||
|
.de-msg-latest { background: #f3f0fb; border-radius: 6px; padding: 6px 8px; margin-bottom: 4px; }
|
||||||
|
.de-msg-badge { display: inline-block; margin-left: 6px; font-size: 9px; font-weight: 700; color: #fff; background: #7e57c2; border-radius: 4px; padding: 0 5px; vertical-align: middle; }
|
||||||
|
.jd-detail { margin-top: 10px; padding: 8px 10px; background: #f6f8fb; border-radius: 6px; white-space: pre-wrap; font-size: 12.5px; color: #333; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
<!--
|
||||||
|
RequiredSkillsField — compétences REQUISES d'un job (store hub /roster/job-skills, PAS un champ ERPNext).
|
||||||
|
Le tech doit les avoir TOUTES ; la 1re = principale (dispatch auto). Module RÉUTILISABLE extrait du volet jobDetail.
|
||||||
|
Réutilise le TagEditor partagé. Le catalogue de tags + la fonction couleur sont fournis par le parent
|
||||||
|
(board = tagCatalog/getTagColor ; ticket = useTagCatalog) — pas de source dupliquée ici.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div class="jd-skill row items-center q-gutter-sm q-mb-xs">
|
||||||
|
<q-icon name="construction" size="16px" color="grey-6" /><span class="text-caption text-grey-7">Compétences requises</span>
|
||||||
|
<TagEditor :model-value="skills" :all-tags="allTags" :get-color="getColor" :can-edit="false"
|
||||||
|
placeholder="Ajouter une compétence requise…" style="min-width:220px;flex:1"
|
||||||
|
@update:model-value="onChange" @create="$emit('create', $event)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import * as roster from 'src/api/roster'
|
||||||
|
import TagEditor from 'src/components/shared/TagEditor.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' }, // docname du Dispatch Job
|
||||||
|
skills: { type: Array, default: () => [] },
|
||||||
|
allTags: { type: Array, default: () => [] },
|
||||||
|
getColor: { type: Function, default: () => '#6b7280' },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['updated', 'create'])
|
||||||
|
|
||||||
|
// TagEditor émet un tableau de libellés (ou de {tag}) ; on tolère aussi une CSV (rétro-compat).
|
||||||
|
function normSkillList (list) {
|
||||||
|
const raw = Array.isArray(list) ? list : String(list || '').split(',')
|
||||||
|
return [...new Set(raw.map(x => (typeof x === 'string' ? x : (x && x.tag) || '')).map(s => String(s).trim()).filter(Boolean))]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChange (list) {
|
||||||
|
const arr = normSkillList(list)
|
||||||
|
if (!props.name) { emit('updated', arr); return }
|
||||||
|
try {
|
||||||
|
await roster.setJobSkills(props.name, arr)
|
||||||
|
emit('updated', arr) // le parent rafraîchit son pool si besoin
|
||||||
|
} catch (e) {
|
||||||
|
Notify.create({ type: 'negative', message: 'Compétences non enregistrées : ' + (e && e.message), timeout: 3000 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
88
apps/ops/src/composables/useAvatar.js
Normal file
88
apps/ops/src/composables/useAvatar.js
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import { reactive } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { HUB_URL } from 'src/config/hub'
|
||||||
|
|
||||||
|
// Store partagé (module-level) : un seul état d'avatars pour toute l'app.
|
||||||
|
// · hasPhoto[email] = true → ce courriel A une photo (sinon on n'affiche QUE les initiales,
|
||||||
|
// sans tenter de <img> → zéro requête 404 pour les membres sans photo).
|
||||||
|
// · versions[email] → jeton de cache-bust (bump après upload/suppression).
|
||||||
|
const hasPhoto = reactive({})
|
||||||
|
const versions = reactive({})
|
||||||
|
|
||||||
|
function keyOf (email) { return String(email || '').toLowerCase() }
|
||||||
|
export function avatarVersion (email) { return versions[keyOf(email)] || '' }
|
||||||
|
export function hasAvatar (email) { return !!hasPhoto[keyOf(email)] }
|
||||||
|
function bump (email, v) { versions[keyOf(email)] = v || Date.now() }
|
||||||
|
|
||||||
|
// Index chargé une seule fois (promesse singleton) : GET /avatars → { avatars: { email: version } }.
|
||||||
|
// Renseigne hasPhoto + versions. Idempotent : plusieurs <UserAvatar> peuvent l'appeler.
|
||||||
|
let _indexP = null
|
||||||
|
export function ensureAvatarIndex () {
|
||||||
|
if (!_indexP) {
|
||||||
|
_indexP = fetch(HUB_URL + '/avatars')
|
||||||
|
.then(r => r.ok ? r.json() : { avatars: {} })
|
||||||
|
.then(data => {
|
||||||
|
for (const [email, v] of Object.entries(data.avatars || {})) {
|
||||||
|
hasPhoto[keyOf(email)] = true
|
||||||
|
versions[keyOf(email)] = v
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { _indexP = null }) // échec → réessayable au prochain montage
|
||||||
|
}
|
||||||
|
return _indexP
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réduit une image (File/Blob) à un carré <= max px, en JPEG base64 (data URL).
|
||||||
|
// Garde les avatars petits (< 1 Mo) et cohérents, sans dépendance externe.
|
||||||
|
export async function fileToAvatarDataUrl (file, max = 256, quality = 0.85) {
|
||||||
|
const dataUrl = await new Promise((resolve, reject) => {
|
||||||
|
const fr = new FileReader()
|
||||||
|
fr.onload = () => resolve(fr.result)
|
||||||
|
fr.onerror = reject
|
||||||
|
fr.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
const img = await new Promise((resolve, reject) => {
|
||||||
|
const i = new Image()
|
||||||
|
i.onload = () => resolve(i)
|
||||||
|
i.onerror = reject
|
||||||
|
i.src = dataUrl
|
||||||
|
})
|
||||||
|
const side = Math.min(img.width, img.height) // recadrage carré centré
|
||||||
|
const sx = (img.width - side) / 2
|
||||||
|
const sy = (img.height - side) / 2
|
||||||
|
const out = Math.min(max, side)
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = out; canvas.height = out
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.drawImage(img, sx, sy, side, side, 0, 0, out, out)
|
||||||
|
return canvas.toDataURL('image/jpeg', quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvatar () {
|
||||||
|
async function uploadAvatar (dataUrl, { as } = {}) {
|
||||||
|
const url = HUB_URL + '/avatars' + (as ? ('?as=' + encodeURIComponent(as)) : '')
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ dataUrl }),
|
||||||
|
})
|
||||||
|
const data = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok) { Notify.create({ type: 'negative', message: 'Photo: ' + (data.error || ('HTTP ' + r.status)) }); return false }
|
||||||
|
hasPhoto[keyOf(data.email || as)] = true
|
||||||
|
bump(data.email || as, data.version)
|
||||||
|
Notify.create({ type: 'positive', message: 'Photo de profil mise à jour', timeout: 1500 })
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteAvatar ({ as } = {}) {
|
||||||
|
const url = HUB_URL + '/avatars' + (as ? ('?as=' + encodeURIComponent(as)) : '')
|
||||||
|
const r = await fetch(url, { method: 'DELETE' })
|
||||||
|
const data = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok) { Notify.create({ type: 'negative', message: 'Photo: ' + (data.error || ('HTTP ' + r.status)) }); return false }
|
||||||
|
hasPhoto[keyOf(data.email || as)] = false
|
||||||
|
bump(data.email || as)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadAvatar, deleteAvatar, fileToAvatarDataUrl, avatarVersion }
|
||||||
|
}
|
||||||
|
|
@ -79,7 +79,8 @@ export function useDetailModal () {
|
||||||
}).catch(() => []),
|
}).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(() => []),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
51
apps/ops/src/composables/useSkillIcons.js
Normal file
51
apps/ops/src/composables/useSkillIcons.js
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
// SOURCE UNIQUE des icônes de compétences (skill → icône). Extraite de PlanificationPage.vue 2026-07-08
|
||||||
|
// pour être réutilisée (fiche client, dispo par motif, etc.) sans dupliquer la carte. Fonctions PURES.
|
||||||
|
import { symOutlinedHeadsetMic } from '@quasar/extras/material-symbols-outlined'
|
||||||
|
|
||||||
|
// Camion-nacelle (bucket truck) pour « monteur » — Material Symbols n'a pas d'équivalent → SVG custom au format
|
||||||
|
// q-icon Quasar (chemins séparés par && ; chaque chemin = d@@style). Aplats (carrosserie/roues/nacelle, roues
|
||||||
|
// évidées en evenodd) + bras articulé en trait épais. Hérite couleur (currentColor) et taille. viewBox 512×416.
|
||||||
|
export const BUCKET_TRUCK = 'M120,300 H440 V322 H120 Z M120,276 H310 V300 H120 Z M206,226 H280 V300 H206 Z M56,64 H160 V144 H56 Z@@fill:currentColor'
|
||||||
|
+ '&&M300,300 V250 L322,222 H408 L438,260 V300 Z M330,252 H396 V284 H330 Z@@fill:currentColor;fill-rule:evenodd'
|
||||||
|
+ '&&M144,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M170,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z M352,356 a40,40 0 1,0 80,0 a40,40 0 1,0 -80,0 Z M378,356 a14,14 0 1,1 28,0 a14,14 0 1,1 -28,0 Z@@fill:currentColor;fill-rule:evenodd'
|
||||||
|
+ '&&M243,250 L384,150 L120,104@@fill:none;stroke:currentColor;stroke-width:30;stroke-linecap:round;stroke-linejoin:round'
|
||||||
|
+ '|0 0 512 416'
|
||||||
|
|
||||||
|
// Icône de COMPÉTENCE (ligature Material OU SVG custom BUCKET_TRUCK). réparation → build, installation → construction,
|
||||||
|
// sans-fil → cell_tower (PtP/tour, ≠ wifi maison), monteur → camion-nacelle, etc.
|
||||||
|
export function skillIcon (sk) {
|
||||||
|
const s = String(sk || '').toLowerCase()
|
||||||
|
if (/install/.test(s)) return 'construction'
|
||||||
|
if (/r[ée]par|d[ée]pann|bris/.test(s)) return 'build'
|
||||||
|
if (/fibre|fusion|soud|épissure|epissure/.test(s)) return 'cable'
|
||||||
|
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv'
|
||||||
|
if (/t[ée]l[ée]phon|voip|\b3cx\b/.test(s)) return 'call'
|
||||||
|
if (/r[ée]seau|net\s?admin|router|bgp|olt|acs/.test(s)) return 'dns'
|
||||||
|
if (/\bwifi\b|wi-?fi|mesh/.test(s)) return 'wifi' // wifi MAISON (couverture interne) → symbole wifi simple
|
||||||
|
if (/sans.?fil|wireless|\bfwa\b|\blte\b|radio|antenne|\btour\b|micro.?onde|point.?[àa].?point|\bptp\b|liaison/.test(s)) return 'cell_tower' // sans fil = PtP inter-bâtiments/tours → tour
|
||||||
|
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return BUCKET_TRUCK // monteur / aérien → camion-nacelle
|
||||||
|
if (/d[ée]sinstall|retrait|ramass|d[ée]mant/.test(s)) return 'delete_sweep'
|
||||||
|
if (/info|ordinateur|\bpc\b|cam[ée]ra/.test(s)) return 'computer'
|
||||||
|
if (/vente|sales|soumission/.test(s)) return 'sell'
|
||||||
|
if (/factur|paiement|compta/.test(s)) return 'receipt_long'
|
||||||
|
return 'handyman' // visite générique (type non reconnu) : technicien avec outils
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variante pour q-icon : quelques symboles dédiés (TV/install/support) sinon délègue à skillIcon.
|
||||||
|
export function skillSym (sk) {
|
||||||
|
const s = String(sk || '').toLowerCase()
|
||||||
|
if (/t[ée]l[ée]vis|\btv\b|iptv|t[ée]l[ée]\b/.test(s)) return 'live_tv' // AVANT « install » (« Install/Reparation Télé » contient « install »)
|
||||||
|
if (/install/.test(s)) return 'router' // installation fibre = pose du routeur/ONT
|
||||||
|
if (/support|service.?client|t[ée]l[ée]assist|\baide\b/.test(s)) return symOutlinedHeadsetMic
|
||||||
|
return skillIcon(sk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icône pour un MARQUEUR carte (DOM, police material-icons) : UNIQUEMENT des ligatures (pas de SVG custom).
|
||||||
|
export function markerIcon (skill) {
|
||||||
|
const s = String(skill || '').toLowerCase()
|
||||||
|
if (/t[ée]l[ée]vis|\btv\b|iptv/.test(s)) return 'live_tv'
|
||||||
|
if (/install/.test(s)) return 'router'
|
||||||
|
if (/monteur|poteau|hauteur|nacelle|a[ée]rien|grimp/.test(s)) return 'local_shipping'
|
||||||
|
const ic = skillIcon(skill)
|
||||||
|
return (typeof ic === 'string' && /^[a-z0-9_]+$/.test(ic)) ? ic : 'build'
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export function useUserGroups ({ permGroups, loadGroupMembers: externalLoadGroup
|
||||||
const userSearchDone = ref(false)
|
const 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,
|
||||||
|
|
|
||||||
|
|
@ -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(() =>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 (F→OPS), pas seulement ceux facturés récemment.</q-tooltip></q-btn></q-card></div>
|
||||||
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-green-8">{{ cs.safe_add }}</div><div class="lbl">À enrichir (sûr)</div></q-card></div>
|
<div class="col-6 col-md-3"><q-card flat bordered class="stat"><div class="num text-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 (F→OPS complet) — passe par le run ASYNC de l'orchestrateur (scope 'customers') → progression + polling, pas de requête bloquante.
|
||||||
|
function confirmCreateAll () {
|
||||||
|
$q.dialog({ title: 'Créer tous les comptes manquants', message: `Créer dans ERPNext les <b>${cs.value.would_create}</b> compte(s) F sans Customer (incl. résiliés) ?<br><span class="text-caption">Politique : créé visible, statut dérivé des services. Idempotent — relançable sans doublon.</span>`, html: true, cancel: true, ok: { label: 'Créer tout', color: 'info' } }).onOk(() => startSync('customers'))
|
||||||
|
}
|
||||||
function confirmApplyAll () {
|
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(' · ')
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,7 @@
|
||||||
</template>
|
</template>
|
||||||
<template #title-suffix>
|
<template #title-suffix>
|
||||||
<span> · <span class="text-primary">#{{ modalTicket?.legacy_ticket_id || modalTicket?.name }}</span></span>
|
<span> · <span class="text-primary">#{{ modalTicket?.legacy_ticket_id || modalTicket?.name }}</span></span>
|
||||||
<template v-if="modalTicket?.customer_name"> · {{ modalTicket.customer_name }}</template>
|
<template v-if="modalTicket?.customer_name"> · <router-link v-if="modalTicket?.customer" :to="'/clients/' + encodeURIComponent(modalTicket.customer)" class="erp-link">{{ modalTicket.customer_name }}</router-link><template v-else>{{ modalTicket.customer_name }}</template></template>
|
||||||
</template>
|
</template>
|
||||||
<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'"
|
||||||
|
|
|
||||||
|
|
@ -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"] } } }
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
110
services/targo-hub/lib/avatars.js
Normal file
110
services/targo-hub/lib/avatars.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
'use strict'
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Photos de profil (avatars) par utilisateur. Stockage serveur keyé par courriel
|
||||||
|
// Authentik → suit l'usager entre appareils. Même patron d'identité que
|
||||||
|
// user-prefs.js : l'identité = x-authentik-email (forward-auth SSO). Un usager ne
|
||||||
|
// peut modifier QUE son propre avatar ; un admin peut cibler un autre via ?as=.
|
||||||
|
//
|
||||||
|
// GET /avatars/{email} → l'image (ou 404). Public-ish (chargée par <img>).
|
||||||
|
// POST /avatars { dataUrl } → définit MON avatar (self)
|
||||||
|
// POST /avatars?as=<e> { dataUrl } → (ADMIN) définit l'avatar d'un autre
|
||||||
|
// DELETE /avatars → supprime MON avatar (self) · ?as= pour admin
|
||||||
|
//
|
||||||
|
// Repli visuel (initiales + couleur) = côté client (<UserAvatar>) sur 404/erreur.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
const fs = require('fs')
|
||||||
|
const path = require('path')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const { json, parseBody, readJsonFile, writeJsonFile } = require('./helpers')
|
||||||
|
|
||||||
|
const DIR = '/app/data/avatars'
|
||||||
|
const INDEX = path.join(DIR, '_index.json') // { emailLower: { ext, mime, ts } }
|
||||||
|
function ensureDir () { if (!fs.existsSync(DIR)) fs.mkdirSync(DIR, { recursive: true }) }
|
||||||
|
|
||||||
|
// SVG volontairement EXCLU (une image de profil SVG = vecteur XSS via <img>/inline).
|
||||||
|
const ALLOWED = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' }
|
||||||
|
const MAX_BYTES = 1024 * 1024 // 1 Mo — les avatars sont petits ; le client redimensionne avant l'envoi.
|
||||||
|
|
||||||
|
const meOf = (req) => String(req.headers['x-authentik-email'] || '').toLowerCase()
|
||||||
|
function isAdmin (req) {
|
||||||
|
const g = String(req.headers['x-authentik-groups'] || req.headers['x-authentik-group'] || '')
|
||||||
|
return /(^|[,|;])\s*(ops-?admin|admin(istrateurs?)?|superviseurs?)\s*([,|;]|$)/i.test(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeDataUrl (dataUrl) {
|
||||||
|
if (typeof dataUrl !== 'string') return null
|
||||||
|
const m = dataUrl.match(/^data:([^;,]+)(;base64)?,(.*)$/)
|
||||||
|
if (!m) return null
|
||||||
|
const mime = m[1].toLowerCase()
|
||||||
|
if (!ALLOWED[mime]) return { error: 'type' }
|
||||||
|
if (!m[2]) return { error: 'type' } // on exige du base64
|
||||||
|
let buffer
|
||||||
|
try { buffer = Buffer.from(m[3], 'base64') } catch { return null }
|
||||||
|
if (!buffer.length) return null
|
||||||
|
if (buffer.length > MAX_BYTES) return { error: 'too_large', size: buffer.length }
|
||||||
|
return { mime, ext: ALLOWED[mime], buffer }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileFor = (email, ext) => path.join(DIR, crypto.createHash('sha1').update(email).digest('hex') + '.' + ext)
|
||||||
|
|
||||||
|
async function handle (req, res, method, path_) {
|
||||||
|
// GET /avatars → index { avatars: { emailLower: version } } : liste des courriels
|
||||||
|
// AYANT une photo (+ version pour cache-bust). Le client s'en sert pour n'afficher
|
||||||
|
// le <img> QUE pour ces courriels et éviter un 404 par avatar sans photo.
|
||||||
|
if (method === 'GET' && (path_ === '/avatars' || path_ === '/avatars/')) {
|
||||||
|
const idx = readJsonFile(INDEX, {})
|
||||||
|
const avatars = {}
|
||||||
|
for (const [email, meta] of Object.entries(idx)) avatars[email] = (meta && meta.ts) || 1
|
||||||
|
res.setHeader('Cache-Control', 'no-cache')
|
||||||
|
return json(res, 200, { avatars })
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /avatars/{email}
|
||||||
|
if (method === 'GET') {
|
||||||
|
const email = decodeURIComponent(path_.replace(/^\/avatars\/?/, '')).trim().toLowerCase()
|
||||||
|
if (!email) return json(res, 400, { error: 'email requis' })
|
||||||
|
const idx = readJsonFile(INDEX, {})
|
||||||
|
const meta = idx[email]
|
||||||
|
if (!meta) { res.statusCode = 404; return res.end() }
|
||||||
|
const p = fileFor(email, meta.ext)
|
||||||
|
if (!fs.existsSync(p)) { res.statusCode = 404; return res.end() }
|
||||||
|
res.setHeader('Content-Type', meta.mime || 'application/octet-stream')
|
||||||
|
// Change de contenu à la même URL → revalidation courte + ?v=<ts> côté client pour le cache-busting immédiat.
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=300, must-revalidate')
|
||||||
|
res.statusCode = 200
|
||||||
|
return res.end(fs.readFileSync(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = meOf(req)
|
||||||
|
const as = String(new URL(req.url, 'http://localhost').searchParams.get('as') || '').toLowerCase()
|
||||||
|
const target = (as && as !== me && isAdmin(req)) ? as : me
|
||||||
|
if (!target) return json(res, 400, { error: 'utilisateur inconnu (x-authentik-email absent)' })
|
||||||
|
if (as && as !== me && !isAdmin(req)) return json(res, 403, { error: 'réservé aux administrateurs' })
|
||||||
|
|
||||||
|
if (method === 'POST') {
|
||||||
|
const b = await parseBody(req)
|
||||||
|
const decoded = decodeDataUrl(b && b.dataUrl)
|
||||||
|
if (!decoded) return json(res, 400, { error: 'dataUrl image invalide' })
|
||||||
|
if (decoded.error === 'type') return json(res, 415, { error: 'format non supporté (png, jpeg, webp, gif)' })
|
||||||
|
if (decoded.error === 'too_large') return json(res, 413, { error: `image trop lourde (${Math.round(decoded.size / 1024)} Ko > 1 Mo)` })
|
||||||
|
ensureDir()
|
||||||
|
const idx = readJsonFile(INDEX, {})
|
||||||
|
// Retire un ancien fichier d'extension différente pour éviter les orphelins.
|
||||||
|
if (idx[target] && idx[target].ext !== decoded.ext) { try { fs.unlinkSync(fileFor(target, idx[target].ext)) } catch { /* absent */ } }
|
||||||
|
fs.writeFileSync(fileFor(target, decoded.ext), decoded.buffer)
|
||||||
|
const ts = Date.now()
|
||||||
|
idx[target] = { ext: decoded.ext, mime: decoded.mime, ts }
|
||||||
|
writeJsonFile(INDEX, idx)
|
||||||
|
return json(res, 200, { ok: true, email: target, version: ts })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
const idx = readJsonFile(INDEX, {})
|
||||||
|
if (idx[target]) { try { fs.unlinkSync(fileFor(target, idx[target].ext)) } catch { /* absent */ } ; delete idx[target]; writeJsonFile(INDEX, idx) }
|
||||||
|
return json(res, 200, { ok: true, email: target })
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, 405, { error: 'method not allowed' })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle }
|
||||||
|
|
@ -2798,4 +2798,7 @@ module.exports = {
|
||||||
parseCsv, parseMapCsv, parseGiftbitCsv,
|
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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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') {
|
||||||
|
|
|
||||||
324
services/targo-hub/lib/staff-agent.js
Normal file
324
services/targo-hub/lib/staff-agent.js
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
'use strict'
|
||||||
|
/**
|
||||||
|
* staff-agent.js — Copilote OPS en langage naturel (Gemini function-calling), surface STAFF.
|
||||||
|
*
|
||||||
|
* BUT (parité UI↔NL) : le langage naturel peut faire ce que fait l'interface visuelle, en exposant
|
||||||
|
* les ACTIONS EXISTANTES comme « outils » function-calling. NL et UI partagent UNE SEULE couche
|
||||||
|
* d'action (les routes hub / fonctions que l'UI appelle déjà) → couverture(NL) = couverture(UI).
|
||||||
|
*
|
||||||
|
* RÉUTILISE l'existant (cf feedback_reuse_shared_components) :
|
||||||
|
* - registre d'outils = services/targo-hub/lib/agent-tools.json (audience 'staff'),
|
||||||
|
* - runtime Gemini function-calling = lib/ai.js `chat({tools, wantMessage})` (même boucle que
|
||||||
|
* lib/agent.js répondeur SMS et lib/roster-assistant.js copilote roster),
|
||||||
|
* - couche d'action = les routes hub EXISTANTES (roster.js/dispatch.js/conversation.js), appelées
|
||||||
|
* en LOOPBACK autorisé (jeton de service + identité x-authentik-email) — zéro logique réécrite.
|
||||||
|
*
|
||||||
|
* SÉCURITÉ (cf feedback_ppa_double_charge_incident — une action auto peut coûter cher) :
|
||||||
|
* - outils LECTURE : s'exécutent librement pendant le PLAN (résolution d'entités, aucun effet).
|
||||||
|
* - outils ÉCRITURE/conséquents : NE s'exécutent PAS via le modèle. Ils sont mis en attente
|
||||||
|
* (staged) avec un APERÇU. L'exécution réelle passe par POST /staff-agent/run APRÈS confirmation
|
||||||
|
* humaine explicite (le modèle propose ; l'humain dispose). Pas d'endpoint arbitraire : /run mappe
|
||||||
|
* type → exécuteur côté serveur (liste blanche), re-valide les permissions (parité usePermissions
|
||||||
|
* via auth.effectiveCapabilities) et agit AU NOM de l'utilisateur connecté (x-authentik-email).
|
||||||
|
*
|
||||||
|
* Routes :
|
||||||
|
* POST /staff-agent { text } → { ok, reply, actions:[{id,type,title,preview,params,permission,needs_confirmation}], transcript }
|
||||||
|
* POST /staff-agent/run { actions } → { ok, results:[{type,ok,message|error}] }
|
||||||
|
* GET /staff-agent/tools → { tools } (inventaire — parité/complétude)
|
||||||
|
*/
|
||||||
|
const cfg = require('./config')
|
||||||
|
const { log, json, parseBody } = require('./helpers')
|
||||||
|
const { toolsForModel, toolsByAudience } = require('./agent')
|
||||||
|
const erp = require('./erp')
|
||||||
|
|
||||||
|
// ── Registre : outils STAFF (audience 'staff') depuis le registre UNIQUE agent-tools.json ──
|
||||||
|
const STAFF_TOOLS_RAW = toolsByAudience('staff')
|
||||||
|
const STAFF_TOOLS = toolsForModel(STAFF_TOOLS_RAW) // envoyés au modèle : {type, function} seulement
|
||||||
|
const META = {} // name → { mode, permission, title }
|
||||||
|
for (const t of STAFF_TOOLS_RAW) META[t.function.name] = { mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name }
|
||||||
|
|
||||||
|
// require paresseux (évite les cycles + n'alourdit pas le boot) — mêmes modules que l'UI appelle.
|
||||||
|
const roster = () => require('./roster')
|
||||||
|
const dispatch = () => require('./dispatch')
|
||||||
|
|
||||||
|
function todayET () { return new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) }
|
||||||
|
|
||||||
|
// ── Loopback AUTORISÉ vers les routes hub EXISTANTES ─────────────────────────
|
||||||
|
// Réutilise EXACTEMENT les handlers que le front appelle (couche d'action unique). On imite le proxy
|
||||||
|
// nginx /ops/hub : Authorization = jeton de service + x-authentik-email = utilisateur connecté (→
|
||||||
|
// attribution, suivi, et vérifs d'identité des handlers). Pas d'endpoint fourni par le client : seuls
|
||||||
|
// les chemins codés dans EXECUTORS sont atteignables.
|
||||||
|
const PORT = cfg.PORT || 3300
|
||||||
|
async function hubCall (method, path, body, email) {
|
||||||
|
const headers = { 'Content-Type': 'application/json' }
|
||||||
|
if (process.env.HUB_SERVICE_TOKEN) headers.Authorization = 'Bearer ' + process.env.HUB_SERVICE_TOKEN
|
||||||
|
if (email) headers['x-authentik-email'] = email
|
||||||
|
const opt = { method, headers }
|
||||||
|
if (body != null && method !== 'GET') opt.body = JSON.stringify(body)
|
||||||
|
const r = await fetch(`http://127.0.0.1:${PORT}${path}`, opt)
|
||||||
|
let data = null
|
||||||
|
try { data = await r.json() } catch { /* corps non-JSON */ }
|
||||||
|
return { status: r.status, ok: r.ok, data: data || {} }
|
||||||
|
}
|
||||||
|
// Un résultat hub → { ok, message } normalisé (les routes renvoient {ok:false,error} ou {error}).
|
||||||
|
function hubResult (r, okMsg) {
|
||||||
|
const ok = r.ok && r.data && r.data.ok !== false && !r.data.error
|
||||||
|
return { ok, message: ok ? okMsg : ((r.data && r.data.error) || `échec (HTTP ${r.status})`), status: r.status, data: r.data }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveTechName (techId) {
|
||||||
|
try { const techs = await roster().fetchTechnicians(); const t = techs.find(x => x.id === techId || x.name === techId); return t ? t.name : techId }
|
||||||
|
catch { return techId }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outils LECTURE (exécutés pendant le PLAN — sans effet de bord) ───────────
|
||||||
|
async function read_list_technicians ({ query, skill } = {}) {
|
||||||
|
let techs
|
||||||
|
try { techs = await roster().fetchTechnicians() } catch (e) { return { error: 'techniciens indisponibles : ' + e.message } }
|
||||||
|
const q = String(query || '').trim().toLowerCase()
|
||||||
|
const sk = String(skill || '').trim().toLowerCase()
|
||||||
|
let out = techs || []
|
||||||
|
if (q) out = out.filter(t => String(t.name || '').toLowerCase().includes(q) || String(t.id || '').toLowerCase().includes(q))
|
||||||
|
if (sk) out = out.filter(t => (t.skills || []).some(s => String(s).toLowerCase().includes(sk)))
|
||||||
|
return { count: out.length, technicians: out.slice(0, 40).map(t => ({ id: t.id, name: t.name, status: t.status, skills: t.skills || [] })) }
|
||||||
|
}
|
||||||
|
async function read_get_job ({ job } = {}) {
|
||||||
|
if (!job) return { error: 'job requis' }
|
||||||
|
try {
|
||||||
|
const d = await erp.get('Dispatch Job', job, { fields: ['name', 'subject', 'status', 'assigned_tech', 'scheduled_date', 'start_time', 'duration_h', 'priority', 'customer_name', 'service_location', 'job_type', 'booking_status'] })
|
||||||
|
if (!d || !d.name) return { error: 'job introuvable : ' + job }
|
||||||
|
return { job: d.name, subject: d.subject, status: d.status, assigned_tech: d.assigned_tech || null, scheduled_date: d.scheduled_date || null, start_time: d.start_time || null, duration_h: d.duration_h, priority: d.priority, customer_name: d.customer_name || '', service_location: d.service_location || '', job_type: d.job_type, booking_status: d.booking_status || '' }
|
||||||
|
} catch (e) { return { error: e.message } }
|
||||||
|
}
|
||||||
|
async function read_find_slot ({ duration_h, skill, after_date } = {}) {
|
||||||
|
try {
|
||||||
|
const slots = await dispatch().suggestSlots({ duration_h: Number(duration_h) || 1, skill: skill || '', after_date: after_date || todayET(), limit: 5 })
|
||||||
|
return { count: (slots || []).length, slots: (slots || []).slice(0, 5) }
|
||||||
|
} catch (e) { return { error: e.message } }
|
||||||
|
}
|
||||||
|
const READERS = { list_technicians: read_list_technicians, get_job: read_get_job, find_slot: read_find_slot }
|
||||||
|
|
||||||
|
// ── Jours de la semaine — clés du weekly_schedule (= DOW_KEYS de roster.js) ──
|
||||||
|
const DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
|
||||||
|
const DAY_FR = { mon: 'Lun', tue: 'Mar', wed: 'Mer', thu: 'Jeu', fri: 'Ven', sat: 'Sam', sun: 'Dim' }
|
||||||
|
const DAY_ALIASES = {
|
||||||
|
mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun',
|
||||||
|
monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun',
|
||||||
|
lun: 'mon', mar: 'tue', mer: 'wed', jeu: 'thu', ven: 'fri', sam: 'sat', dim: 'sun',
|
||||||
|
lundi: 'mon', mardi: 'tue', mercredi: 'wed', jeudi: 'thu', vendredi: 'fri', samedi: 'sat', dimanche: 'sun',
|
||||||
|
}
|
||||||
|
function normDays (days) {
|
||||||
|
const set = new Set()
|
||||||
|
for (const d of (Array.isArray(days) ? days : [])) { const k = DAY_ALIASES[String(d || '').trim().toLowerCase()]; if (k) set.add(k) }
|
||||||
|
return DAY_KEYS.filter(k => set.has(k)) // ordre lun→dim, dédupliqué
|
||||||
|
}
|
||||||
|
function normTime (t, dflt = '') {
|
||||||
|
const m = String(t || '').trim().match(/^(\d{1,2})(?:\s*[:hH]\s*(\d{2}))?/) // "8", "8:00", "8h", "16h00"
|
||||||
|
if (!m) return dflt
|
||||||
|
return String(Math.min(23, parseInt(m[1], 10))).padStart(2, '0') + ':' + (m[2] || '00')
|
||||||
|
}
|
||||||
|
function buildWeeklySchedule (days, start, end) {
|
||||||
|
const st = normTime(start), en = normTime(end)
|
||||||
|
const sched = {}
|
||||||
|
for (const k of normDays(days)) sched[k] = { start: st, end: en }
|
||||||
|
return sched
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Outils ÉCRITURE — { permission, build(params)→{title,preview,params[,severity]}, exec(params,email) } ──
|
||||||
|
// build : mis en attente pendant le plan (aperçu). exec : exécution réelle après confirmation (via /run),
|
||||||
|
// chaque exec = fine enveloppe sur une route/fonction hub EXISTANTE.
|
||||||
|
const WRITES = {
|
||||||
|
create_job: {
|
||||||
|
permission: 'create_jobs',
|
||||||
|
async build (p) {
|
||||||
|
const params = { subject: String(p.subject || '').trim(), customer_id: p.customer_id || '', service_location: p.service_location || '', priority: p.priority || '', job_type: p.job_type || '', notes: p.notes || '', auto_assign: p.auto_assign !== false }
|
||||||
|
const preview = `« ${params.subject || 'Intervention'} »${params.job_type ? ' · ' + params.job_type : ''}${params.priority ? ' · priorité ' + params.priority : ''}${params.customer_id ? ' · client ' + params.customer_id : ''} · ${params.auto_assign ? 'assignation auto' : 'non assigné'}`
|
||||||
|
return { title: META.create_job.title, preview, params }
|
||||||
|
},
|
||||||
|
async exec (p) {
|
||||||
|
const r = await dispatch().agentCreateDispatchJob({ customer_id: p.customer_id || '', service_location: p.service_location || '', subject: p.subject, priority: p.priority || undefined, job_type: p.job_type || undefined, notes: p.notes || '', auto_assign: p.auto_assign })
|
||||||
|
return { ok: !!(r && r.success), message: (r && r.message) || 'job créé', job_id: r && r.job_id, assigned_tech: r && r.assigned_tech }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
assign_tech: {
|
||||||
|
permission: 'assign_jobs',
|
||||||
|
async build (p) {
|
||||||
|
const name = await resolveTechName(p.tech_id)
|
||||||
|
const params = { job: p.job, tech: p.tech_id, date: p.date || '', start: p.start || '' }
|
||||||
|
return { title: META.assign_tech.title, preview: `${p.job} → ${name} (${p.tech_id})${p.date ? ' · ' + p.date : ''}${p.start ? ' à ' + p.start : ''}`, params }
|
||||||
|
},
|
||||||
|
async exec (p, email) {
|
||||||
|
const body = { job: p.job, tech: p.tech }
|
||||||
|
if (p.date) body.date = p.date
|
||||||
|
if (p.start) body.start = p.start
|
||||||
|
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} assigné à ${p.tech}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
add_assistant: {
|
||||||
|
permission: 'assign_jobs',
|
||||||
|
async build (p) {
|
||||||
|
const name = p.tech_name || await resolveTechName(p.tech_id)
|
||||||
|
return { title: META.add_assistant.title, preview: `Renfort sur ${p.job} : ${name} (${p.tech_id})`, params: { job: p.job, tech_id: p.tech_id, tech_name: name } }
|
||||||
|
},
|
||||||
|
async exec (p, email) {
|
||||||
|
return hubResult(await hubCall('POST', '/roster/job/team', { job: p.job, add: { tech_id: p.tech_id, tech_name: p.tech_name || '' } }, email), `Assistant ${p.tech_id} ajouté à ${p.job}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
set_job_status: {
|
||||||
|
permission: 'assign_jobs',
|
||||||
|
async build (p) {
|
||||||
|
return { title: META.set_job_status.title, preview: `${p.job} → statut « ${p.status} »`, params: { job: p.job, status: p.status }, severity: p.status === 'Cancelled' ? 'high' : 'normal' }
|
||||||
|
},
|
||||||
|
async exec (p, email) {
|
||||||
|
return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { status: p.status } }, email), `${p.job} → ${p.status}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reschedule_job: {
|
||||||
|
permission: 'assign_jobs',
|
||||||
|
async build (p) {
|
||||||
|
return { title: META.reschedule_job.title, preview: `${p.job} reporté au ${p.date}${p.start ? ' à ' + p.start : ''}`, params: { job: p.job, date: p.date, start: p.start || '' } }
|
||||||
|
},
|
||||||
|
async exec (p, email) {
|
||||||
|
// Conserve le tech assigné si possible (re-place l'heure via /roster/assign-job) ; sinon change la date via /roster/job/update.
|
||||||
|
let tech = null
|
||||||
|
try { const d = await erp.get('Dispatch Job', p.job, { fields: ['assigned_tech'] }); tech = d && d.assigned_tech } catch { /* fallback ci-dessous */ }
|
||||||
|
if (tech) {
|
||||||
|
const body = { job: p.job, tech, date: p.date }
|
||||||
|
if (p.start) body.start = p.start
|
||||||
|
return hubResult(await hubCall('POST', '/roster/assign-job', body, email), `${p.job} reporté au ${p.date}`)
|
||||||
|
}
|
||||||
|
return hubResult(await hubCall('POST', '/roster/job/update', { job: p.job, patch: { scheduled_date: p.date } }, email), `${p.job} reporté au ${p.date}`)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create_recurring_shift: {
|
||||||
|
permission: 'assign_jobs',
|
||||||
|
async build (p) {
|
||||||
|
const days = normDays(p.days)
|
||||||
|
const schedule = buildWeeklySchedule(p.days, p.start, p.end)
|
||||||
|
const name = await resolveTechName(p.technicien_id)
|
||||||
|
const st = normTime(p.start), en = normTime(p.end)
|
||||||
|
const worked = days.map(k => DAY_FR[k]).join(', ') || '(aucun jour)'
|
||||||
|
const off = DAY_KEYS.filter(k => !days.includes(k)).map(k => DAY_FR[k]).join(', ')
|
||||||
|
const preview = `${name} (${p.technicien_id}) : ${st}–${en} le ${worked}${off ? ` · repos ${off}` : ''}`
|
||||||
|
// schedule pré-calculé porté dans les params → /run le pousse tel quel sur la route weekly-schedule.
|
||||||
|
return { title: META.create_recurring_shift.title, preview, params: { technicien_id: p.technicien_id, schedule } }
|
||||||
|
},
|
||||||
|
async exec (p, email) {
|
||||||
|
if (!p.schedule || typeof p.schedule !== 'object' || !Object.keys(p.schedule).length) return { ok: false, message: 'horaire vide (aucun jour)' }
|
||||||
|
return hubResult(await hubCall('POST', `/roster/technician/${encodeURIComponent(p.technicien_id)}/weekly-schedule`, { schedule: p.schedule }, email), 'Horaire récurrent enregistré (quarts matérialisés au prochain cycle)')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
follow_doc: {
|
||||||
|
permission: null, // suivi = personnel/bas risque → tout staff authentifié
|
||||||
|
async build (p) {
|
||||||
|
const follow = p.follow !== false
|
||||||
|
return { title: META.follow_doc.title, preview: `${follow ? 'Suivre' : 'Ne plus suivre'} ${p.doctype} ${p.name}`, params: { doctype: p.doctype, name: p.name, follow } }
|
||||||
|
},
|
||||||
|
async exec (p, email) {
|
||||||
|
return hubResult(await hubCall('POST', '/conversations/follow', { doctype: p.doctype, name: p.name, follow: p.follow !== false }, email), (p.follow !== false) ? 'Suivi activé' : 'Suivi retiré')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Runtime Gemini (function-calling) — même config/boucle que lib/agent.js & roster-assistant.js ──
|
||||||
|
async function geminiChat (messages) {
|
||||||
|
const msg = await require('./ai').chat({ task: 'nl', messages, tools: STAFF_TOOLS, maxTokens: 900, temperature: 0.1, wantMessage: true })
|
||||||
|
return { choices: [{ message: msg }] }
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPrompt (email) {
|
||||||
|
return `Tu es le COPILOTE OPS (dispatch/planification) de Gigafibre/TARGO, fournisseur Internet/TV/téléphonie au Québec.
|
||||||
|
Aujourd'hui = ${todayET()}. Tu agis AU NOM de l'utilisateur connecté${email ? ` (${email})` : ''}.
|
||||||
|
Tu transformes une commande en langage naturel en APPELS D'OUTILS.
|
||||||
|
RÈGLES :
|
||||||
|
- Utilise d'abord les outils de LECTURE (list_technicians, get_job, find_slot) pour RÉSOUDRE les entités. Résous toujours un technicien nommé (« Simon ») en tech_id via list_technicians avant d'agir.
|
||||||
|
- Les outils d'ÉCRITURE (create_job, assign_tech, add_assistant, set_job_status, reschedule_job, create_recurring_shift, follow_doc) NE S'EXÉCUTENT PAS tout de suite : ils sont PROPOSÉS puis CONFIRMÉS par l'utilisateur. Chaque appel te renvoie un APERÇU (staged=true). N'affirme JAMAIS qu'une action est faite ; annonce ce qui SERA fait après confirmation.
|
||||||
|
- Horaire/quart RÉCURRENT : days parmi mon,tue,wed,thu,fri,sat,sun. « jours de semaine » = mon,tue,wed,thu,fri ; retire les exceptions (« sauf le vendredi » → mon,tue,wed,thu). « fin de semaine » = sat,sun. Convertis « 8-16h » en start 08:00 / end 16:00. Il FAUT un technicien : si aucun n'est nommé, DEMANDE-le.
|
||||||
|
- S'il manque une information essentielle (quel job ? quel tech ? quelle date ?), pose UNE question courte au lieu d'appeler un outil d'écriture.
|
||||||
|
- Réponds en français, bref. Après avoir proposé des actions, résume-les en une phrase et invite à confirmer.`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execToolPlan (name, args, ctx) {
|
||||||
|
const meta = META[name]
|
||||||
|
if (!meta) return { error: 'outil inconnu : ' + name }
|
||||||
|
try {
|
||||||
|
if (meta.mode === 'read') { const fn = READERS[name]; return fn ? await fn(args || {}) : { error: 'lecteur manquant : ' + name } }
|
||||||
|
const w = WRITES[name]
|
||||||
|
if (!w) return { error: 'exécuteur manquant : ' + name }
|
||||||
|
const staged = await w.build(args || {}, ctx.email)
|
||||||
|
const id = 'a' + (ctx.planned.length + 1)
|
||||||
|
ctx.planned.push({ id, type: name, title: staged.title, preview: staged.preview, params: staged.params, permission: meta.permission || null, severity: staged.severity || 'normal', needs_confirmation: true })
|
||||||
|
return { staged: true, requires_confirmation: true, title: staged.title, preview: staged.preview, note: "Action PROPOSÉE, NON exécutée. L'utilisateur doit la confirmer. Ne dis pas qu'elle est faite." }
|
||||||
|
} catch (e) { return { error: String(e.message || e) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// PLAN : boucle function-calling. Les LECTURES s'exécutent (résolution), les ÉCRITURES sont mises en attente.
|
||||||
|
async function plan (text, email) {
|
||||||
|
if (!cfg.AI_API_KEY) return { ok: false, error: "Le service IA n'est pas configuré (AI_API_KEY manquante)." }
|
||||||
|
const planned = []
|
||||||
|
const ctx = { email, planned }
|
||||||
|
const messages = [{ role: 'system', content: systemPrompt(email) }, { role: 'user', content: String(text || '') }]
|
||||||
|
let response
|
||||||
|
try { response = await geminiChat(messages) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
|
||||||
|
const cur = [...messages]
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const msg = response.choices?.[0]?.message
|
||||||
|
if (!msg) break
|
||||||
|
const calls = msg.tool_calls // signal OpenAI-compat des appels d'outils (indépendant de finish_reason)
|
||||||
|
if (!calls || !calls.length) break
|
||||||
|
cur.push(msg)
|
||||||
|
for (const tc of calls) {
|
||||||
|
const args = (() => { try { return JSON.parse(tc.function.arguments || '{}') } catch { return {} } })()
|
||||||
|
log(`[staff-agent] ${email || 'anon'} → ${tc.function.name}(${JSON.stringify(args)})`)
|
||||||
|
const result = await execToolPlan(tc.function.name, args, ctx)
|
||||||
|
cur.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result).slice(0, 6000) })
|
||||||
|
}
|
||||||
|
try { response = await geminiChat(cur) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
|
||||||
|
}
|
||||||
|
const reply = response.choices?.[0]?.message?.content || (planned.length ? 'Plan préparé — confirme pour exécuter.' : 'Aucune action détectée — reformule.')
|
||||||
|
return { ok: true, reply, actions: planned, transcript: String(text || '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
// RUN : exécute les actions CONFIRMÉES. type → exécuteur (liste blanche) + re-vérif permission + identité.
|
||||||
|
async function run (actions, email) {
|
||||||
|
const list = Array.isArray(actions) ? actions.slice(0, 12) : []
|
||||||
|
let caps = null
|
||||||
|
try { caps = await require('./auth').effectiveCapabilities(email) } catch (e) { log('[staff-agent] perms indispo : ' + e.message) }
|
||||||
|
const results = []
|
||||||
|
for (const a of list) {
|
||||||
|
const type = a && a.type
|
||||||
|
const w = WRITES[type]
|
||||||
|
if (!w) { results.push({ type, ok: false, error: 'action inconnue' }); continue }
|
||||||
|
// Permissions (parité usePermissions) : appliquées si Authentik est branché (prod). En dev/aperçu
|
||||||
|
// (pas de token → caps.configured=false) on n'enforce pas mais on journalise — le gate du hub
|
||||||
|
// (jeton de service) garantit déjà que seul un membre du staff authentifié atteint /staff-agent.
|
||||||
|
const need = w.permission
|
||||||
|
if (need && caps && caps.configured) {
|
||||||
|
const allowed = caps.is_superuser || (caps.capabilities && caps.capabilities[need] === true)
|
||||||
|
if (!allowed) { results.push({ type, ok: false, denied: true, error: `permission requise : ${need}` }); continue }
|
||||||
|
}
|
||||||
|
try { results.push({ type, ...(await w.exec(a.params || {}, email)) }) }
|
||||||
|
catch (e) { results.push({ type, ok: false, error: String(e.message || e) }) }
|
||||||
|
}
|
||||||
|
return { ok: results.length > 0 && results.every(r => r.ok), results }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle (req, res, method, path) {
|
||||||
|
const email = String(req.headers['x-authentik-email'] || '').toLowerCase()
|
||||||
|
if (path === '/staff-agent' && method === 'POST') {
|
||||||
|
const b = await parseBody(req)
|
||||||
|
try { return json(res, 200, await plan(b.text || '', email)) }
|
||||||
|
catch (e) { return json(res, 200, { ok: false, error: 'Erreur copilote : ' + (e.message || e) }) }
|
||||||
|
}
|
||||||
|
if (path === '/staff-agent/run' && method === 'POST') {
|
||||||
|
const b = await parseBody(req)
|
||||||
|
try { return json(res, 200, await run(b.actions, email)) }
|
||||||
|
catch (e) { return json(res, 200, { ok: false, error: 'Erreur exécution : ' + (e.message || e) }) }
|
||||||
|
}
|
||||||
|
if (path === '/staff-agent/tools' && method === 'GET') {
|
||||||
|
return json(res, 200, { tools: STAFF_TOOLS_RAW.map(t => ({ name: t.function.name, mode: t.mode || 'read', permission: t.permission || null, title: t.title || t.function.name, description: t.function.description })) })
|
||||||
|
}
|
||||||
|
return json(res, 404, { error: 'staff-agent: route inconnue ' + path })
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { handle, plan, run, buildWeeklySchedule, normDays, normTime, STAFF_TOOLS_RAW }
|
||||||
|
|
@ -80,8 +80,11 @@ const CORE_STEPS = [
|
||||||
{ key: 'soldes', label: 'Soldes des factures', fn: async () => { const r = await require('./legacy-payments').refreshOpenInvoices({ confirm: 'F-WINS' }); return `${r.updated || 0} soldé(s)` } },
|
{ 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' }) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user