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:
parent
60f6d71615
commit
788d969090
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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">🗓 Horaires & personnel</q-item-label>
|
<q-item-label header class="q-py-xs text-weight-bold text-grey-8">🗓 Horaires & 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; }
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }) }
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user