From 788d9690906c502079f52b6c0cec6f8e988383a6 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sun, 19 Jul 2026 20:06:47 -0400 Subject: [PATCH] =?UTF-8?q?chore(reconcile):=20commit=20MES=20hunks=20d?= =?UTF-8?q?=C3=A9ploy=C3=A9s=20dans=20les=20fichiers=20co-=C3=A9dit=C3=A9s?= =?UTF-8?q?=20(split=20via=20git=20apply=20--cached)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réconciliation de l'audit : isole et commite UNIQUEMENT mon travail déployé-non-commité dans 7 fichiers co-édités, sans toucher au working tree (donc sans capturer le travail en cours des autres sessions, qui reste non commité) : - server.js : SSE event 'ready' + retry:3000 ; routes /olt/onu/plan|run + /olt/wifi-clients. - conversation.js : endpoint /conversations/my-inbox-counts (accueil : en attente / suivis / en retard). - useConversations.js : armSSE (reconnexion+resync+repli poll 25s) + cleanup. - PlanificationPage.vue : chips géofence (Lane 1c) + sélecteur « Générer l'horaire » (Move 3) + isLate (arrivée en retard). - SettingsPage.vue : section « Thème & couleurs » (ThemeEditor). - IssueDetail.vue + ConversationPanel.vue : props source-issue/customer/service-location/phone (« Proposer au client »). EXCLUS (restent non commités, propriété d'autres sessions) : /service-location, /conversations/msg-visibility, retrait champ Mapbox, fix conv-message 'belongs', hiérarchie parent_incident, JobMediaModule, etc. Vérifié : 0 marqueur étranger dans le staged. Tout est déjà LIVE ; ceci n'aligne que le dépôt. Co-Authored-By: Claude Fable 5 --- .../components/shared/ConversationPanel.vue | 5 +- .../shared/detail-sections/IssueDetail.vue | 3 + apps/ops/src/composables/useConversations.js | 14 ++++- apps/ops/src/pages/PlanificationPage.vue | 55 ++++++++++++++++--- apps/ops/src/pages/SettingsPage.vue | 10 ++++ services/targo-hub/lib/conversation.js | 33 +++++++++++ services/targo-hub/server.js | 21 ++++++- 7 files changed, 129 insertions(+), 12 deletions(-) diff --git a/apps/ops/src/components/shared/ConversationPanel.vue b/apps/ops/src/components/shared/ConversationPanel.vue index c65e895..5226f7c 100644 --- a/apps/ops/src/components/shared/ConversationPanel.vue +++ b/apps/ops/src/components/shared/ConversationPanel.vue @@ -1018,7 +1018,10 @@ + :customer-name="(activeDiscussion && (activeDiscussion.customerName || activeDiscussion.email || activeDiscussion.phone)) || ''" + :customer="coordCustomer || ''" + :source-issue="(linkedTickets && linkedTickets[0] && linkedTickets[0].name) || ''" + :phone="(activeDiscussion && activeDiscussion.phone) || ''" /> diff --git a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue index 82a5bdd..595f149 100644 --- a/apps/ops/src/components/shared/detail-sections/IssueDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/IssueDetail.vue @@ -316,6 +316,9 @@ v-model="availReasonOpen" :initial-text="[doc.subject, doc.description].filter(Boolean).join(' — ')" :customer-name="customerLabel || doc.customer || ''" + :source-issue="doc.name || docName || ''" + :customer="doc.customer || ''" + :service-location="doc.service_location || ''" @book="onAvailBook" /> diff --git a/apps/ops/src/composables/useConversations.js b/apps/ops/src/composables/useConversations.js index 65948bd..7c4c059 100644 --- a/apps/ops/src/composables/useConversations.js +++ b/apps/ops/src/composables/useConversations.js @@ -28,6 +28,15 @@ const composePrefill = ref(null) // { channel, to, phone, subject, html, text, c function openComposeDraft (payload) { composePrefill.value = payload || null; if (payload && payload.channel) newDialogChannel.value = payload.channel; newDialogOpen.value = true } const selectedIds = ref(new Set()) let sseSource = null +let ssePoll = null +// Résilience SSE (survit aux redéploiements du hub qui coupent tous les flux en mémoire) : sur (RE)connexion → resync +// fetchList() (rattrape les courriels arrivés pendant la coupure) + coupe le repli ; sur fermeture PERMANENTE (502 pendant +// le restart → EventSource CLOSED, plus de reconnexion native) → repli poll 25 s + reconnexion manuelle. reconnectFn re-crée+re-arme. +function armSSE (reconnectFn) { + if (!sseSource) return + sseSource.onopen = () => { if (ssePoll) { clearInterval(ssePoll); ssePoll = null } fetchList() } + sseSource.onerror = () => { if (sseSource && sseSource.readyState === EventSource.CLOSED) { if (!ssePoll) ssePoll = setInterval(() => fetchList(), 25000); setTimeout(reconnectFn, 3000) } } +} // Présence agent : quel autre agent OPS est en train d'écrire dans une conversation. { [convToken]: agentEmail } const agentTyping = ref({}) @@ -182,6 +191,7 @@ export function useConversations () { sseSource.addEventListener('conv-closed', handleConvClosed) sseSource.addEventListener('conv-deleted', () => fetchList()) sseSource.addEventListener('conv-email', e => { try { const d = JSON.parse(e.data); fetchList(); notifyNewEmail(d) } catch (er) { /* */ } }) // NOUVEAU courriel ingéré → rafraîchit la liste EN DIRECT + notif navigateur + armSSE(() => connectDiscussionSSE(tokens)) } function updateListFromMessage (data) { @@ -221,6 +231,7 @@ export function useConversations () { sseSource.addEventListener('conv-closed', handleConvClosed) sseSource.addEventListener('conv-deleted', () => fetchList()) sseSource.addEventListener('conv-email', e => { try { const d = JSON.parse(e.data); fetchList(); notifyNewEmail(d) } catch (er) { /* */ } }) // NOUVEAU courriel ingéré → rafraîchit la liste EN DIRECT + notif navigateur + armSSE(() => connectSSE(token)) } function connectGlobalSSE () { @@ -232,6 +243,7 @@ export function useConversations () { sseSource.addEventListener('conv-draft', e => { try { handleConvDraft(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-deleted', () => fetchList()) sseSource.addEventListener('conv-email', e => { try { const d = JSON.parse(e.data); fetchList(); notifyNewEmail(d) } catch (er) { /* */ } }) // NOUVEAU courriel ingéré → rafraîchit la liste EN DIRECT + notif navigateur + armSSE(connectGlobalSSE) } // Recherche FLOUE omnichannel (SQL pg_trgm côté hub) : nom client / courriel / sujet + corps des messages, typo-tolérante. @@ -531,7 +543,7 @@ export function useConversations () { const selectedDiscussions = computed(() => discussions.value.filter(d => selectedIds.value.has(d.id))) - function disconnectSSE () { if (sseSource) { sseSource.close(); sseSource = null } } + function disconnectSSE () { if (ssePoll) { clearInterval(ssePoll); ssePoll = null } if (sseSource) { sseSource.close(); sseSource = null } } function goBack () { activeToken.value = null; activeConv.value = null; activeDiscussion.value = null diff --git a/apps/ops/src/pages/PlanificationPage.vue b/apps/ops/src/pages/PlanificationPage.vue index eb0cef4..b1c9e0a 100644 --- a/apps/ops/src/pages/PlanificationPage.vue +++ b/apps/ops/src/pages/PlanificationPage.vue @@ -14,13 +14,12 @@ Semaine précédente Semaine suivante - Générer (auto) + Générer l'horaire… Congés / absences Notifier par SMS Créer un job Trouver un créneau - Générer les horaires (lot) Publier au legacy Rafraîchir @@ -86,8 +85,7 @@ 🗓  Horaires & personnel - Générer l'horaire (semaine)solveur de quarts — la semaine affichée - Générer les horaires (lot)modèle → plusieurs techs, N semaines + Générer l'horaire…solveur auto (semaine) ou modèles de quarts (lot) Congés / absences Synchroniser les techniciens @@ -535,10 +533,10 @@
📞 Sur appel (garde) {{ fmtH(g.s) }}h–{{ fmtH(g.e) }}h — soir / fin de semaine
@@ -828,6 +837,7 @@ import QueueTeamsCard from 'src/components/settings/QueueTeamsCard.vue' import DepartmentSubscriptions from 'src/components/settings/DepartmentSubscriptions.vue' import CannedResponsesCard from 'src/components/settings/CannedResponsesCard.vue' import AiRoutingCard from 'src/components/settings/AiRoutingCard.vue' +import ThemeEditor from 'src/components/shared/ThemeEditor.vue' // ex-page /theme, consolidé dans les Paramètres import SlaSettings from 'src/components/settings/SlaSettings.vue' import { useLegacySync } from 'src/composables/useLegacySync' diff --git a/services/targo-hub/lib/conversation.js b/services/targo-hub/lib/conversation.js index 02c7fa8..1d331f9 100644 --- a/services/targo-hub/lib/conversation.js +++ b/services/targo-hub/lib/conversation.js @@ -1274,6 +1274,39 @@ async function handle (req, res, method, p, url) { } catch (e) { log('inbox-tickets error:', e.message); return json(res, 200, { tickets: [], error: e.message }) } } + // Décompte pour l'accueil — chiffres HONNÊTES tirés des VRAIES sources (pas d'_assign ERPNext, quasi vide ici) : + // • inboxActive = conversations ACTIVES (store PG en mémoire) = vrai volume de boîte. + // • mine / mineOverdue = tickets que JE SUIS encore ouverts (le suivi = « Mes tickets » d'OPS), en retard si ouvert > 48 h. + if (p === '/conversations/my-inbox-counts' && method === 'GET') { + try { + const email = String((url && url.searchParams && url.searchParams.get('email')) || req.headers['x-authentik-email'] || '').toLowerCase() + // awaiting = conversations actives dont le DERNIER message vient du client (= en attente de NOTRE réponse) → ACTIONNABLE. + let inboxActive = 0, awaiting = 0 + try { + for (const c of conversations.values()) { + if ((c.status || 'active') !== 'active') continue + inboxActive++ + const m = c.messages || []; const last = m[m.length - 1] + if (last && (last.from === 'customer' || last.from === 'client' || last.direction === 'inbound')) awaiting++ + } + } catch (e) {} + let mine = 0, mineOverdue = 0 + if (email) { + const follows = (followsFor(loadFollows(), email, 'Issue') || []).slice(0, 500) + if (follows.length) { + const cntIssue = async (f) => { + try { const r = await erp.raw('/api/method/frappe.client.get_count?doctype=Issue&filters=' + encodeURIComponent(JSON.stringify(f))); return (r && r.data && typeof r.data.message === 'number') ? r.data.message : 0 } catch (e) { return 0 } + } + const base = [['name', 'in', follows], ['status', 'in', ['Open', 'Replied']]] + const cut = new Date(Date.now() - 2 * 86400000).toISOString().slice(0, 19).replace('T', ' ') // ouvert > 48 h + const r = await Promise.all([cntIssue(base), cntIssue(base.concat([['creation', '<', cut]]))]) + mine = r[0]; mineOverdue = r[1] + } + } + return json(res, 200, { inboxActive, awaiting, mine, mineOverdue }) + } catch (e) { return json(res, 200, { inboxActive: 0, awaiting: 0, mine: 0, mineOverdue: 0, error: e.message }) } + } + // TRIAGE IA (Phase 3) — aperçu : classe un courriel (client connu ? type ? suggérer ticket ?). Réutilisé par le poller mail. if (p === '/conversations/triage-preview' && method === 'POST') { try { const b = await parseBody(req); const r = await require('./inbox-triage').classifyEmail(b || {}); return json(res, 200, r) } catch (e) { return json(res, 500, { error: e.message }) } diff --git a/services/targo-hub/server.js b/services/targo-hub/server.js index cd763e7..4e3e9be 100644 --- a/services/targo-hub/server.js +++ b/services/targo-hub/server.js @@ -177,7 +177,10 @@ const server = http.createServer(async (req, res) => { const topics = (url.searchParams.get('topics') || '').split(',').map(t => t.trim()).filter(Boolean) if (!topics.length) return json(res, 400, { error: 'Missing topics parameter' }) res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' }) - res.write(': connected\n\n') + // `retry:` = délai de reconnexion natif ; puis un VRAI event `ready` (pas un commentaire `:`) → le client peut s'y + // abonner pour RESYNCHRONISER à chaque (re)connexion (ex. après un redéploiement du hub qui a coupé le flux). + res.write('retry: 3000\n') + res.write('event: ready\ndata: {"ts":' + Date.now() + '}\n\n') sse.addClient(topics, res, email) const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000) res.on('close', () => clearInterval(keepalive)) @@ -340,6 +343,22 @@ const server = http.createServer(async (req, res) => { oltSnmp.registerOlt(body) return json(res, 200, { ok: true }) } + // ── Écritures cycle-de-vie ONU (G2, tech-3 via n8n) — plan(=lecture seule) / run(=fire, gardé) ── + if (oltParts[0] === 'onu') { + const oltOps = require('./lib/olt-ops') + if (oltParts[1] === 'plan' && method === 'GET') { + const q = url.searchParams + return json(res, 200, await oltOps.plan({ serial: q.get('serial') || '', action: q.get('action') || '', olt: q.get('olt') || undefined, new_sn: q.get('new_sn') || undefined, profileid: q.get('profileid') || undefined })) + } + if (oltParts[1] === 'run' && method === 'POST') { + const body = await parseBody(req) + return json(res, 200, await oltOps.run({ ...body, actor: req.headers['x-authentik-email'] || body.actor || '' })) + } + return json(res, 404, { error: 'OLT ONU endpoint not found' }) + } + if (oltParts[0] === 'wifi-clients' && method === 'GET') { + return json(res, 200, await require('./lib/olt-ops').wifiClients({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '' })) + } return json(res, 404, { error: 'OLT endpoint not found' }) } catch (e) { return json(res, 500, { error: 'OLT module error: ' + e.message })