chore(reconcile): commit MES hunks déployés dans les fichiers co-édités (split via git apply --cached)

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 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-19 20:06:47 -04:00
parent 60f6d71615
commit 788d969090
7 changed files with 129 additions and 12 deletions

View File

@ -1018,7 +1018,10 @@
<!-- « Dispo par motif » depuis un courriel/SMS : sujet pré-rempli l'IA/les chips évaluent la compétence disponibilité filtrée --> <!-- « Dispo par motif » depuis un courriel/SMS : sujet pré-rempli l'IA/les chips évaluent la compétence disponibilité filtrée -->
<AvailabilityByReason v-model="availReasonOpen" <AvailabilityByReason v-model="availReasonOpen"
:initial-text="convSubject || (activeDiscussion && activeDiscussion.subject) || ''" :initial-text="convSubject || (activeDiscussion && activeDiscussion.subject) || ''"
:customer-name="(activeDiscussion && (activeDiscussion.customerName || activeDiscussion.email || activeDiscussion.phone)) || ''" /> :customer-name="(activeDiscussion && (activeDiscussion.customerName || activeDiscussion.email || activeDiscussion.phone)) || ''"
:customer="coordCustomer || ''"
:source-issue="(linkedTickets && linkedTickets[0] && linkedTickets[0].name) || ''"
:phone="(activeDiscussion && activeDiscussion.phone) || ''" />
<!-- 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" />

View File

@ -316,6 +316,9 @@
v-model="availReasonOpen" v-model="availReasonOpen"
:initial-text="[doc.subject, doc.description].filter(Boolean).join(' — ')" :initial-text="[doc.subject, doc.description].filter(Boolean).join(' — ')"
:customer-name="customerLabel || doc.customer || ''" :customer-name="customerLabel || doc.customer || ''"
:source-issue="doc.name || docName || ''"
:customer="doc.customer || ''"
:service-location="doc.service_location || ''"
@book="onAvailBook" @book="onAvailBook"
/> />
</template> </template>

View File

@ -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 } function openComposeDraft (payload) { composePrefill.value = payload || null; if (payload && payload.channel) newDialogChannel.value = payload.channel; newDialogOpen.value = true }
const selectedIds = ref(new Set()) const selectedIds = ref(new Set())
let sseSource = null 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 } // Présence agent : quel autre agent OPS est en train d'écrire dans une conversation. { [convToken]: agentEmail }
const agentTyping = ref({}) const agentTyping = ref({})
@ -182,6 +191,7 @@ export function useConversations () {
sseSource.addEventListener('conv-closed', handleConvClosed) sseSource.addEventListener('conv-closed', handleConvClosed)
sseSource.addEventListener('conv-deleted', () => fetchList()) 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 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) { function updateListFromMessage (data) {
@ -221,6 +231,7 @@ export function useConversations () {
sseSource.addEventListener('conv-closed', handleConvClosed) sseSource.addEventListener('conv-closed', handleConvClosed)
sseSource.addEventListener('conv-deleted', () => fetchList()) 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 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 () { function connectGlobalSSE () {
@ -232,6 +243,7 @@ export function useConversations () {
sseSource.addEventListener('conv-draft', e => { try { handleConvDraft(JSON.parse(e.data)) } catch {} }) sseSource.addEventListener('conv-draft', e => { try { handleConvDraft(JSON.parse(e.data)) } catch {} })
sseSource.addEventListener('conv-deleted', () => fetchList()) 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 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. // 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))) 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 () { function goBack () {
activeToken.value = null; activeConv.value = null; activeDiscussion.value = null activeToken.value = null; activeConv.value = null; activeDiscussion.value = null

View File

@ -14,13 +14,12 @@
<q-item clickable v-close-popup @click="navWeek(-1)"><q-item-section avatar><q-icon name="chevron_left" /></q-item-section><q-item-section>Semaine précédente</q-item-section></q-item> <q-item clickable v-close-popup @click="navWeek(-1)"><q-item-section avatar><q-icon name="chevron_left" /></q-item-section><q-item-section>Semaine précédente</q-item-section></q-item>
<q-item clickable v-close-popup @click="navWeek(1)"><q-item-section avatar><q-icon name="chevron_right" /></q-item-section><q-item-section>Semaine suivante</q-item-section></q-item> <q-item clickable v-close-popup @click="navWeek(1)"><q-item-section avatar><q-icon name="chevron_right" /></q-item-section><q-item-section>Semaine suivante</q-item-section></q-item>
<q-separator /> <q-separator />
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="primary" /></q-item-section><q-item-section>Générer (auto)</q-item-section></q-item> <q-item clickable v-close-popup @click="schedGenChooser = true"><q-item-section avatar><q-icon name="auto_awesome" color="primary" /></q-item-section><q-item-section>Générer l'horaire</q-item-section></q-item>
<q-item clickable v-close-popup @click="showLeave = true"><q-item-section avatar><q-icon name="beach_access" color="teal" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item> <q-item clickable v-close-popup @click="showLeave = true"><q-item-section avatar><q-icon name="beach_access" color="teal" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
<q-separator /> <q-separator />
<q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item> <q-item tag="label" clickable><q-item-section avatar><q-icon name="sms" /></q-item-section><q-item-section>Notifier par SMS</q-item-section><q-item-section side><q-toggle v-model="notifySms" dense /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item> <q-item clickable v-close-popup @click="openCreateJob"><q-item-section avatar><q-icon name="add_task" color="teal-7" /></q-item-section><q-item-section>Créer un job</q-item-section></q-item>
<q-item clickable v-close-popup @click="showSuggestSlots = true"><q-item-section avatar><q-icon name="event_available" color="teal-8" /></q-item-section><q-item-section>Trouver un créneau</q-item-section></q-item> <q-item clickable v-close-popup @click="showSuggestSlots = true"><q-item-section avatar><q-icon name="event_available" color="teal-8" /></q-item-section><q-item-section>Trouver un créneau</q-item-section></q-item>
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)</q-item-section></q-item>
<q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item> <q-item clickable v-close-popup @click="openLegacyPush"><q-item-section avatar><q-icon name="sync_alt" color="deep-orange" /></q-item-section><q-item-section>Publier au legacy</q-item-section></q-item>
<q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item> <q-item clickable v-close-popup @click="() => guard(loadWeek)"><q-item-section avatar><q-icon name="refresh" /></q-item-section><q-item-section>Rafraîchir</q-item-section></q-item>
</q-list> </q-list>
@ -86,8 +85,7 @@
<q-separator class="q-my-xs" /> <q-separator class="q-my-xs" />
<!-- 🗓 HORAIRES & PERSONNEL : génération de quarts, absences, synchro --> <!-- 🗓 HORAIRES & PERSONNEL : génération de quarts, absences, synchro -->
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">🗓&nbsp; Horaires &amp; personnel</q-item-label> <q-item-label header class="q-py-xs text-weight-bold text-grey-8">🗓&nbsp; Horaires &amp; personnel</q-item-label>
<q-item clickable v-close-popup @click="doGenerate"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire (semaine)<q-item-label caption>solveur de quarts la semaine affichée</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item> <q-item clickable v-close-popup @click="schedGenChooser = true"><q-item-section avatar><q-icon name="auto_awesome" color="indigo" /></q-item-section><q-item-section>Générer l'horaire<q-item-label caption>solveur auto (semaine) ou modèles de quarts (lot)</q-item-label></q-item-section><q-item-section side><q-spinner v-if="generating" size="16px" color="indigo" /></q-item-section></q-item>
<q-item clickable v-close-popup @click="openSchedGenBulk"><q-item-section avatar><q-icon name="event_note" color="primary" /></q-item-section><q-item-section>Générer les horaires (lot)<q-item-label caption>modèle plusieurs techs, N semaines</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="showLeave = true"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item> <q-item clickable v-close-popup @click="showLeave = true"><q-item-section avatar><q-icon name="beach_access" /></q-item-section><q-item-section>Congés / absences</q-item-section></q-item>
<q-item clickable v-close-popup @click="showTechSync = true"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item> <q-item clickable v-close-popup @click="showTechSync = true"><q-item-section avatar><q-icon name="group_add" color="green-7" /></q-item-section><q-item-section>Synchroniser les techniciens</q-item-section></q-item>
<q-separator class="q-my-xs" /> <q-separator class="q-my-xs" />
@ -535,10 +533,10 @@
<div v-for="(g, gi) in kbGardeBands(t.id)" :key="'g' + gi" class="kbb-garde" :style="kbPos(g.s, g.e)"><q-icon name="phone_in_talk" color="negative" size="13px" /><q-tooltip class="bg-grey-9" style="font-size:11px">📞 Sur appel (garde) {{ fmtH(g.s) }}h{{ fmtH(g.e) }}h soir / fin de semaine</q-tooltip></div> <div v-for="(g, gi) in kbGardeBands(t.id)" :key="'g' + gi" class="kbb-garde" :style="kbPos(g.s, g.e)"><q-icon name="phone_in_talk" color="negative" size="13px" /><q-tooltip class="bg-grey-9" style="font-size:11px">📞 Sur appel (garde) {{ fmtH(g.s) }}h{{ fmtH(g.e) }}h soir / fin de semaine</q-tooltip></div>
<template v-for="(b, bi) in kanbanLaneBlocks(t.id)" :key="'j' + bi + (b.assist ? 'a' : '')"> <template v-for="(b, bi) in kanbanLaneBlocks(t.id)" :key="'j' + bi + (b.assist ? 'a' : '')">
<!-- trajet d'approche (km/min) = zone pâle AVANT le job --> <!-- trajet d'approche (km/min) = zone pâle AVANT le job -->
<div v-if="b.legMin" class="kbb-leg" :style="kbPos(b.legS, b.legE)"><span class="kbb-leg-t">{{ b.legMin }}</span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px">{{ b.legReal ? '🚗 Trajet routier (Mapbox)' : ' Trajet estimé' }} : {{ b.legKm }} km · {{ b.legMin }} min avant ce job</q-tooltip></div> <div v-if="b.legMin" class="kbb-leg" :style="kbPos(b.legS, b.legE)"><span class="kbb-leg-t">{{ b.legMin }}</span><q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px">{{ b.legReal ? '🚗 Trajet routier (OSRM/OSM)' : ' Trajet estimé' }} : {{ b.legKm }} km · {{ b.legMin }} min avant ce job</q-tooltip></div>
<div class="kbb-blk" :class="{ assist: b.assist, 'kbb-blk-legacy': b.legacy, 'kbb-blk-opsonly': blkOpsOnly(b) }" :style="{ ...kbPos(b.s, Math.min(b.e, 24)), ...blkFill(b) }" :draggable="!b.assist && !b.legacy" @dragstart="!b.assist && !b.legacy && onJobDragStart($event, b)" @dragend="onJobDragEnd" @click.stop="b.legacy ? openLegacyBlock(b) : (!b.assist && kbBlockClick(t))" @dblclick.stop="!b.legacy && kbBlockDbl(b, t)"> <div class="kbb-blk" :class="{ assist: b.assist, 'kbb-blk-legacy': b.legacy, 'kbb-blk-opsonly': blkOpsOnly(b), 'kbb-blk-late': isLate(b) }" :style="{ ...kbPos(b.s, Math.min(b.e, 24)), ...blkFill(b) }" :draggable="!b.assist && !b.legacy" @dragstart="!b.assist && !b.legacy && onJobDragStart($event, b)" @dragend="onJobDragEnd" @click.stop="b.legacy ? openLegacyBlock(b) : (!b.assist && kbBlockClick(t))" @dblclick.stop="!b.legacy && kbBlockDbl(b, t)">
<div class="kbb-blk-l1"><q-icon :name="blkIcon(b)" size="12px" :style="{ color: blkColor(b) }" /><span class="kbb-blk-t">{{ b.subject }}</span></div> <div class="kbb-blk-l1"><q-icon :name="blkIcon(b)" size="12px" :style="{ color: blkColor(b) }" /><span class="kbb-blk-t">{{ b.subject }}</span></div>
<div class="kbb-blk-l2">{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<span v-if="b.legacy"> · {{ b.dept }}</span><span v-else-if="b.customer"> · {{ b.customer }}</span><span v-if="b.assist"> · assistant</span></div> <div class="kbb-blk-l2">{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<span v-if="b.legacy"> · {{ b.dept }}</span><span v-else-if="b.customer"> · {{ b.customer }}</span><span v-if="b.assist"> · assistant</span><span v-if="isLate(b)" style="color:#d2483d;font-weight:800"> · ⚠ en retard</span><span v-else-if="!b.legacy && geoChip(b.name)" :style="{ color: geoChip(b.name).color, fontWeight: 700 }"> · {{ geoChip(b.name).label }}</span></div>
<q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px"><template v-if="b.legacy">🗂 <b>{{ b.subject }}</b><br>{{ b.dept || 'osTicket' }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h · #{{ b.lid }}<br><span class="text-light-green-4"> Assigné dans F (autoritaire)</span><br><span class="text-info">clic = ouvrir le ticket </span></template><template v-else>{{ b.subject }}<template v-if="b.customer"><br>{{ b.customer }}</template><template v-if="b.address"><br>📍 {{ b.address }}</template><template v-if="b.assist"><br>👥 assistant (renfort)</template><br>{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<template v-if="blkOpsOnly(b)"><br><span class="text-warning"> Dispatché OPS pas encore publié dans F</span></template><template v-if="!b.assist"><br><span class="text-info">clic droit = renvoyer au pool</span></template></template></q-tooltip> <q-tooltip class="bg-grey-9" :delay="300" style="font-size:11px"><template v-if="b.legacy">🗂 <b>{{ b.subject }}</b><br>{{ b.dept || 'osTicket' }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h · #{{ b.lid }}<br><span class="text-light-green-4"> Assigné dans F (autoritaire)</span><br><span class="text-info">clic = ouvrir le ticket </span></template><template v-else>{{ b.subject }}<template v-if="b.customer"><br>{{ b.customer }}</template><template v-if="b.address"><br>📍 {{ b.address }}</template><template v-if="b.assist"><br>👥 assistant (renfort)</template><br>{{ fmtH(b.s) }} · {{ Math.round((b.dur || 0) * 10) / 10 }}h<template v-if="blkOpsOnly(b)"><br><span class="text-warning"> Dispatché OPS pas encore publié dans F</span></template><template v-if="!b.assist"><br><span class="text-info">clic droit = renvoyer au pool</span></template></template></q-tooltip>
<q-menu v-if="!b.assist && !b.legacy" context-menu touch-position auto-close><q-list dense style="min-width:190px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool<q-item-label caption>désassigner ce tech</q-item-label></q-item-section></q-item><q-item clickable @click="openJobDetail(b, t)"><q-item-section avatar><q-icon name="open_in_full" color="grey-7" /></q-item-section><q-item-section>Détails / équipe</q-item-section></q-item></q-list></q-menu> <q-menu v-if="!b.assist && !b.legacy" context-menu touch-position auto-close><q-list dense style="min-width:190px"><q-item clickable @click="unassignOne(b)"><q-item-section avatar><q-icon name="inbox" color="grey-7" /></q-item-section><q-item-section>Renvoyer au pool<q-item-label caption>désassigner ce tech</q-item-label></q-item-section></q-item><q-item clickable @click="openJobDetail(b, t)"><q-item-section avatar><q-icon name="open_in_full" color="grey-7" /></q-item-section><q-item-section>Détails / équipe</q-item-section></q-item></q-list></q-menu>
</div> </div>
@ -1738,6 +1736,20 @@
<ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" /> <ProjectWizard v-model="projectWizardOpen" :customer="quoteCustomer" :initial-tier="quoteTier" @created="onQuoteCreated" />
<!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment --> <!-- Génération de quarts hebdo (modèles + N semaines + LOT multi-techs) écrit les Shift Assignment -->
<WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" /> <WeeklyScheduleEditor v-model="schedGenOpen" :techs="schedGenTechs" :tech-name="schedGenTechs[0] && schedGenTechs[0].name" @apply="onScheduleApply" />
<!-- Chooser « Générer l'horaire » : 2 mécanismes distincts sous une seule entrée (règle « pas de menu-dans-menu »). -->
<q-dialog v-model="schedGenChooser">
<q-card style="min-width:320px;max-width:440px">
<q-card-section class="text-subtitle1 text-weight-bold">Générer l'horaire</q-card-section>
<q-card-section class="q-pt-none column q-gutter-sm">
<q-btn no-caps unelevated color="indigo" icon="auto_awesome" align="left" @click="schedGenChooser = false; doGenerate()">
<div class="q-ml-sm text-left"><div>Solveur automatique</div><div class="text-caption">optimise les quarts de la semaine affichée</div></div>
</q-btn>
<q-btn no-caps outline color="primary" icon="event_note" align="left" @click="schedGenChooser = false; openSchedGenBulk()">
<div class="q-ml-sm text-left"><div>Modèles de quarts</div><div class="text-caption">appliquer un modèle à plusieurs techs, N semaines</div></div>
</q-btn>
</q-card-section>
</q-card>
</q-dialog>
<TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" :pending-abs="pendingAbs" :pending-paused="!!(techSchedTech && pendingPause[techSchedTech.id])" :garde-map="gardeEffective" :refresh-key="techSchedRefresh" <TechScheduleDialog v-model="techSchedOpen" :tech="techSchedTech" :pending-abs="pendingAbs" :pending-paused="!!(techSchedTech && pendingPause[techSchedTech.id])" :garde-map="gardeEffective" :refresh-key="techSchedRefresh"
@edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" @stage-abs="onStageAbs" @stage-pause="onStagePause" @edit-schedule="onTechSchedEdit" @changed="onTechSchedChanged" @stage-abs="onStageAbs" @stage-pause="onStagePause"
@set-shift="onTechSchedSetShift" @remove-shift="onTechSchedRemoveShift" @clear-shifts="onTechSchedClearShifts" @toggle-garde="onTechSchedToggleGarde" /> @set-shift="onTechSchedSetShift" @remove-shift="onTechSchedRemoveShift" @clear-shifts="onTechSchedClearShifts" @toggle-garde="onTechSchedToggleGarde" />
@ -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é). // 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 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) 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 } 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, // 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. // 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 () { async function doGenerate () {
generating.value = true 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 { 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: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.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é */ .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-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; } .kbb-blk-l2 { font-size: 9.5px; font-weight: 500; color: #6a7686; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

View File

@ -803,6 +803,15 @@
<!-- Réponses pré-écrites (canned) réutilisables dans l'Inbox --> <!-- Réponses pré-écrites (canned) réutilisables dans l'Inbox -->
<CannedResponsesCard class="q-mt-md" /> <CannedResponsesCard class="q-mt-md" />
<!-- Thème & couleurs (ex-page /theme, consolidé ici une entrée de nav en moins) -->
<div v-if="can('view_settings')" class="ops-card q-mt-md">
<q-expansion-item header-class="section-header" expand-icon-class="text-grey-7">
<template #header><SectionHeader icon="palette" label="Thème & couleurs" /></template>
<q-separator />
<div class="q-pa-md"><ThemeEditor /></div>
</q-expansion-item>
</div>
</q-page> </q-page>
</template> </template>
@ -828,6 +837,7 @@ 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'
import AiRoutingCard from 'src/components/settings/AiRoutingCard.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 SlaSettings from 'src/components/settings/SlaSettings.vue'
import { useLegacySync } from 'src/composables/useLegacySync' import { useLegacySync } from 'src/composables/useLegacySync'

View File

@ -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 }) } } 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. // 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') { 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 }) } 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 }) }

View File

@ -177,7 +177,10 @@ const server = http.createServer(async (req, res) => {
const topics = (url.searchParams.get('topics') || '').split(',').map(t => t.trim()).filter(Boolean) const topics = (url.searchParams.get('topics') || '').split(',').map(t => t.trim()).filter(Boolean)
if (!topics.length) return json(res, 400, { error: 'Missing topics parameter' }) 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.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) sse.addClient(topics, res, email)
const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000) const keepalive = setInterval(() => { try { res.write(': ping\n\n') } catch { clearInterval(keepalive) } }, 25000)
res.on('close', () => clearInterval(keepalive)) res.on('close', () => clearInterval(keepalive))
@ -340,6 +343,22 @@ const server = http.createServer(async (req, res) => {
oltSnmp.registerOlt(body) oltSnmp.registerOlt(body)
return json(res, 200, { ok: true }) 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' }) return json(res, 404, { error: 'OLT endpoint not found' })
} catch (e) { } catch (e) {
return json(res, 500, { error: 'OLT module error: ' + e.message }) return json(res, 500, { error: 'OLT module error: ' + e.message })