+
{{ b.legMin }}′{{ b.legReal ? '🚗 Trajet routier (OSRM/OSM)' : '≈ Trajet estimé' }} : {{ b.legKm }} km · {{ b.legMin }} min avant ce job
+
{{ b.subject }}
-
{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h · {{ b.dept }} · {{ b.customer }} · assistant
+
{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h · {{ b.dept }} · {{ b.customer }} · assistant · ⚠ en retard · {{ geoChip(b.name).label }}
🗂 {{ b.subject }}
{{ b.dept || 'osTicket' }} · ≈{{ Math.round((b.dur || 0) * 10) / 10 }}h · #{{ b.lid }}
✓ Assigné dans F (autoritaire)
clic = ouvrir le ticket ↗{{ b.subject }}
{{ b.customer }}
📍 {{ b.address }}
👥 assistant (renfort)
{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h
⚠ Dispatché OPS — pas encore publié dans F
clic droit = renvoyer au pool
Renvoyer au pooldésassigner ce techDétails / équipe
@@ -1738,6 +1736,20 @@
+
+
+
+ Générer l'horaire
+
+
+ Solveur automatique
optimise les quarts de la semaine affichée
+
+
+ Modèles de quarts
appliquer un modèle à plusieurs techs, N semaines
+
+
+
+
@@ -2694,7 +2706,7 @@ async function onTechSchedChanged (ev) {
}
// ── Génération de quarts HEBDO par tech (modèles + N semaines) — écrit DIRECTEMENT les Shift Assignment (Publié). ──
-const schedGenOpen = ref(false); const schedGenTechs = ref([])
+const schedGenOpen = ref(false); const schedGenTechs = ref([]); const schedGenChooser = ref(false) // chooser : solveur auto (doGenerate) vs modèles de quarts (openSchedGenBulk) — 1 entrée « Générer l'horaire… »
function openSchedGen (t) { schedGenTechs.value = [{ id: t.id, name: t.name }]; skillMenuShown.value = false; schedGenOpen.value = true } // 1 tech
function openSchedGenBulk () { schedGenTechs.value = (visibleTechs.value || []).map(t => ({ id: t.id, name: t.name, skills: t.skills || [] })); schedGenOpen.value = true } // LOT : tous les techs visibles (+ compétences pour filtrer)
const _hmNum = (s) => { const [h, m] = String(s || '').split(':').map(Number); return (h || 0) + (m || 0) / 60 }
@@ -5684,7 +5696,31 @@ async function loadStats () {
}
// Refresh CIBLÉ + rapide de l'occupation (recharge les blocs/jobs des cellules → timeline à jour) après une assignation,
// sans le loadWeek complet (assignations + couverture + stats), bien plus lent.
-async function reloadOccupancy () { try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {} } catch (e) { /* non bloquant */ } }
+async function reloadOccupancy () { try { const o = await roster.getOccupancy(start.value, $q.screen.lt.md ? MOBILE_STRIP_DAYS : days.value); occByTechDay.value = o.occupancy || {}; refreshGeofenceStates() } catch (e) { /* non bloquant */ } }
+// ── Géofence LIVE sur les cartes du board (En route / Arrivé / Reparti) — l'état vient du suivi Traccar (hub), n'existe
+// que pour le JOUR COURANT. On récupère les états en LOT pour les jobs visibles d'aujourd'hui (endpoint /roster/geofence-states).
+const geofenceStates = ref({})
+const GEO_CHIP = { en_route: { label: 'En route', color: '#2563eb', icon: 'directions_car' }, on_site: { label: 'Arrivé', color: '#16a34a', icon: 'location_on' }, departed: { label: 'Reparti', color: '#64748b', icon: 'check_circle' } }
+function geoChip (name) { const s = name && geofenceStates.value[name]; return (s && GEO_CHIP[s]) || null }
+// EN RETARD (aujourd'hui seulement) : début prévu passé de > 20 min mais le géofence ne montre PAS le tech arrivé
+// (ni reparti) → retard d'arrivée à signaler au répartiteur. Réactif (nowET tique, geofenceStates rafraîchi).
+function isLate (b) {
+ if (!b || b.legacy || b.assist || b.name == null || b.s == null) return false
+ if (!kbIsToday.value) return false
+ const n = nowET.value; if (n.h == null || (n.h - b.s) <= 0.34) return false
+ const s = geofenceStates.value[b.name]
+ return s !== 'on_site' && s !== 'departed'
+}
+async function refreshGeofenceStates () {
+ try {
+ const today = (nowET && nowET.iso) || new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
+ const names = []
+ for (const t of (visibleTechs.value || [])) for (const j of cellJobs(t.id, today)) { if (j && j.name && !j.cancelled) names.push(j.name) }
+ if (!names.length) { geofenceStates.value = {}; return }
+ const r = await roster.geofenceStates([...new Set(names)])
+ geofenceStates.value = (r && r.states) || {}
+ } catch (e) { /* non bloquant */ }
+}
async function doGenerate () {
generating.value = true
@@ -6399,6 +6435,7 @@ tr.res-hidden .hide-eye { opacity: 1; }
.kbb-blk { position: absolute; top: 7px; bottom: 7px; border-radius: 5px; background: #fff; color: #1c2533; border: 1.5px solid #cfd6e0; border-left-width: 4px; display: flex; flex-direction: column; justify-content: center; gap: 1px; padding: 2px 7px; cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,.12); overflow: hidden; } /* carte BLANCHE, contour + icône colorés (skill), texte noir — style Gaiia */
.kbb-blk:hover { box-shadow: 0 2px 8px rgba(0,0,0,.22); z-index: 2; }
.kbb-blk.assist { border-style: dashed !important; background: #eceff1 !important; color: #90a4ae; opacity: .6; cursor: default; } /* assistant (renfort) = créneau GRISÉ (réservé, secondaire) */
+.kbb-blk.kbb-blk-late { border-color: #d2483d !important; border-left-color: #d2483d !important; box-shadow: 0 0 0 1px #d2483d, 0 1px 3px rgba(210,72,61,.3); } /* arrivée en retard = contour rouge (le tech devrait être sur place) */
.tl-blk.tl-blk-assist { opacity: .5; filter: grayscale(0.55); border: 1px dashed rgba(120,144,176,.7) !important; } /* idem dans la grille semaine : bloc assistant grisé */
.kbb-blk-l1 { display: flex; align-items: center; gap: 3px; font-size: 11px; font-weight: 700; line-height: 1.2; }
.kbb-blk-l2 { font-size: 9.5px; font-weight: 500; color: #6a7686; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
diff --git a/apps/ops/src/pages/SettingsPage.vue b/apps/ops/src/pages/SettingsPage.vue
index 61bc08a..fc553e7 100644
--- a/apps/ops/src/pages/SettingsPage.vue
+++ b/apps/ops/src/pages/SettingsPage.vue
@@ -803,6 +803,15 @@
+
+
+
@@ -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 })