gigafibre-fsm/apps/ops/src/components/settings/QueueTeamsCard.vue
louispaulb 512c4a5f1b feat(ops): dispatch auto complet + perf Boîte/rapports + fix session
Planificateur « Suggérer » : 4 stratégies (smart / meilleurs d'abord / équilibré /
juste ce qu'il faut), compétences+niveaux par tech (édition inline), niveau requis
par compétence + par job, carte des tournées (1 couleur/tech, domicile→arrêts,
sélecteur de jour), fenêtre de dispatch auj.+demain (dates sélectionnées), règle
week-end + placeholder « en attente du quart », clustering + lasso + filtre-date
sur la carte, accès rapide « À assigner » (badge).

Boîte : liste /conversations allégée (45 Mo → ~1 Mo, 17×) + messages chargés à
l'ouverture. Rapports : cache SWR sur revenue-explorer (22×). Session : keep-alive
+ timeout fetch global + authFetch durci → fin des rechargements manuels.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 08:49:35 -04:00

186 lines
10 KiB
Vue

<template>
<div class="ops-card q-mb-md">
<div class="row items-center q-mb-xs">
<q-icon name="inbox" size="20px" color="primary" class="q-mr-sm" />
<span class="text-subtitle1 text-weight-bold">Files (Inbox) & équipes</span>
<q-space />
<q-spinner v-if="loading" size="16px" color="primary" />
</div>
<div class="text-caption text-grey-6 q-mb-md">
Qui reçoit et traite les demandes de chaque file. À l'arrivée d'une demande, ses membres sont notifiés (courriel + Inbox). Même logique que les groupes de droits — on cherche des utilisateurs et on les ajoute.
</div>
<!-- Liste des files -->
<div class="row q-col-gutter-sm">
<div class="col-12 col-sm-6 col-md-3" v-for="q in queues" :key="q">
<div class="queue-card" :class="{ sel: selected === q }" @click="select(q)">
<div class="row items-center no-wrap">
<q-icon name="groups" size="18px" :color="selected === q ? 'primary' : 'grey-6'" class="q-mr-sm" />
<span class="text-weight-medium">{{ label(q) }}</span>
<q-space />
<q-badge :color="(members[q] || []).length ? 'green-1' : 'grey-3'" :text-color="(members[q] || []).length ? 'green-8' : 'grey-6'">
{{ (members[q] || []).length }}
</q-badge>
</div>
</div>
</div>
</div>
<!-- Détail file -->
<q-slide-transition>
<div v-if="selected" class="queue-detail q-mt-md">
<div class="row items-center q-mb-sm">
<q-icon name="groups" size="22px" color="primary" class="q-mr-sm" />
<span class="text-h6">{{ label(selected) }}</span>
<q-space />
<q-btn flat round dense icon="close" @click="selected = null" />
</div>
<!-- Nom d'affichage (ex. singulier) — le nom réel (clé, = F, souvent au pluriel) reste inchangé -->
<div class="row items-center q-gutter-sm q-mb-md">
<q-input dense outlined v-model="labelDraft" label="Nom affiché" style="min-width:220px" @keyup.enter="saveLabel" @blur="saveLabel">
<template #append><q-icon v-if="(labels[selected] || selected) !== labelDraft" name="edit" size="16px" color="green-5" /></template>
</q-input>
<span class="text-caption text-grey-6">Nom réel (F) : <b>{{ selected }}</b> — inchangé</span>
</div>
<div class="text-subtitle2 q-mb-xs">Membres ({{ (members[selected] || []).length }})</div>
<div v-for="email in (members[selected] || [])" :key="email" class="row items-center q-py-xs q-px-sm qm-member-row">
<q-avatar size="26px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:.7rem">{{ initial(email) }}</q-avatar>
<span class="text-body2">{{ email }}</span>
<q-space />
<q-btn flat round dense icon="person_remove" size="sm" color="negative" class="qm-del" @click="removeMember(email)"><q-tooltip>Retirer de la file</q-tooltip></q-btn>
</div>
<div v-if="!(members[selected] || []).length" class="text-caption text-grey-5 q-py-sm">Aucun membre — personne n'est notifié pour cette file.</div>
<!-- Ajouter un membre (recherche utilisateurs Authentik) -->
<div class="q-mt-sm" style="position:relative">
<q-input v-model="search" label="Ajouter un membre…" outlined dense autocomplete="off" name="qm-add-nope" @update:model-value="onSearch">
<template #append>
<q-spinner v-if="searching" size="16px" color="primary" />
<q-icon v-else-if="search" name="close" class="cursor-pointer" @click="search = ''; results = []" />
</template>
</q-input>
<div v-if="results.length" class="qm-dropdown">
<div v-for="u in results" :key="u.email || u.pk" class="qm-item" @click="addMember(u)">
<q-avatar size="24px" color="green-1" text-color="green-8" class="q-mr-sm" style="font-size:.65rem">{{ initial(u.email || u.name) }}</q-avatar>
<span class="text-body2">{{ u.name || u.username || u.email }}</span>
<span class="text-caption text-grey-6 q-ml-sm">{{ u.email }}</span>
<q-badge v-if="(members[selected] || []).includes((u.email || '').toLowerCase())" label="déjà membre" color="grey-3" text-color="grey-6" class="q-ml-auto" />
</div>
</div>
</div>
<!-- Importer tout un groupe Authentik (la table explicite reste la source) -->
<div class="row items-center q-gutter-sm q-mt-sm">
<q-select v-model="importGroupName" :options="groupsList" dense outlined options-dense style="min-width:200px" label="Importer un groupe Authentik" clearable emit-value map-options />
<q-btn dense unelevated color="green-5" icon="group_add" label="Importer les membres" :disable="!importGroupName" :loading="importing" no-caps @click="importGroup" />
</div>
</div>
</q-slide-transition>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useQuasar } from 'quasar'
import { HUB_URL } from 'src/config/hub'
const $q = useQuasar()
const queues = ref(['Facturations', 'Service à la clientèle', 'Supports', 'Technicien'])
const members = ref({})
const selected = ref(null)
const loading = ref(false)
const search = ref('')
const searching = ref(false)
const results = ref([])
const groupsList = ref([])
const importGroupName = ref(null)
const importing = ref(false)
const labels = ref({}) // nom réel (clé) → libellé d'affichage
const labelDraft = ref('')
let _t = null
function initial (s) { return String(s || '?').trim().charAt(0).toUpperCase() }
function label (q) { return labels.value[q] || q } // affichage (singulier possible) ; la clé reste le nom réel
async function load () {
loading.value = true
try {
const r = await fetch(`${HUB_URL}/conversations/queue-members`)
if (r.ok) { const d = await r.json(); members.value = d.members || {}; labels.value = d.labels || {}; if (d.queues && d.queues.length) queues.value = d.queues }
} catch (e) { /* */ } finally { loading.value = false }
try {
const g = await fetch(`${HUB_URL}/auth/groups`)
if (g.ok) { const d = await g.json(); groupsList.value = (d.groups || d || []).map(x => ({ label: `${x.name}${x.num_users != null ? ' (' + x.num_users + ')' : ''}`, value: x.name })) }
} catch (e) { /* */ }
}
async function importGroup () {
if (!importGroupName.value || !selected.value) return
importing.value = true
try {
const r = await fetch(`${HUB_URL}/auth/users?group=${encodeURIComponent(importGroupName.value)}`)
const d = r.ok ? await r.json() : {}
const emails = (d.users || d || []).map(u => String(u.email || '').toLowerCase()).filter(Boolean)
const list = members.value[selected.value] || (members.value[selected.value] = [])
let added = 0
emails.forEach(e => { if (!list.includes(e)) { list.push(e); added++ } })
if (added) { await save(); $q.notify({ type: 'positive', message: `${added} membre(s) de « ${importGroupName.value} » ajouté(s) à ${selected.value}` }) }
else $q.notify({ type: 'info', message: 'Tous ces membres sont déjà dans la file', timeout: 1500 })
importGroupName.value = null
} catch (e) { $q.notify({ type: 'negative', message: 'Échec import groupe' }) } finally { importing.value = false }
}
function select (q) { selected.value = selected.value === q ? null : q; search.value = ''; results.value = []; labelDraft.value = selected.value ? label(selected.value) : '' }
function onSearch (v) {
clearTimeout(_t)
const q = String(v || '').trim()
if (q.length < 2) { results.value = []; return }
_t = setTimeout(async () => {
searching.value = true
try { const r = await fetch(`${HUB_URL}/auth/users?search=${encodeURIComponent(q)}`); if (r.ok) { const d = await r.json(); results.value = (d.users || d || []).filter(u => u && u.email).slice(0, 8) } } catch (e) { results.value = [] } finally { searching.value = false }
}, 300)
}
async function save () {
try { await fetch(`${HUB_URL}/conversations/queue-members`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ members: members.value, labels: labels.value }) }) } catch (e) { $q.notify({ type: 'negative', message: 'Échec sauvegarde' }) }
}
function saveLabel () {
if (!selected.value) return
const v = String(labelDraft.value || '').trim()
const cur = labels.value[selected.value] || ''
if (v && v !== selected.value) { if (cur === v) return; labels.value = { ...labels.value, [selected.value]: v } } // libellé personnalisé
else { if (!cur) return; const cp = { ...labels.value }; delete cp[selected.value]; labels.value = cp } // vide ou = nom réel → retire le libellé
save(); $q.notify({ type: 'positive', message: 'Nom affiché mis à jour', timeout: 1200 })
}
function addMember (u) {
const email = String(u.email || '').toLowerCase(); if (!email || !selected.value) return
const list = members.value[selected.value] || (members.value[selected.value] = [])
if (!list.includes(email)) { list.push(email); save(); $q.notify({ type: 'positive', message: `${email} ajouté à ${selected.value}`, timeout: 1500 }) }
search.value = ''; results.value = []
}
function removeMember (email) {
if (!selected.value) return
members.value[selected.value] = (members.value[selected.value] || []).filter(e => e !== email)
save()
}
onMounted(load)
</script>
<style scoped>
.queue-card { border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 12px; cursor: pointer; transition: all .12s; }
.queue-card:hover { border-color: #a5b4fc; }
.queue-card.sel { border-color: #6366f1; background: #eef2ff; }
.queue-detail { border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; background: #fafbff; }
.qm-dropdown { position: absolute; z-index: 20; left: 0; right: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.12); margin-top: 2px; max-height: 240px; overflow-y: auto; }
.qm-item { display: flex; align-items: center; padding: 7px 10px; cursor: pointer; }
.qm-item:hover { background: #f1f5f9; }
/* Survol de la ligne membre → viser le bon « Retirer » dans une longue liste */
.qm-member-row { border-bottom: 1px solid #f1f5f9; border-radius: 6px; transition: background .1s; }
.qm-member-row:hover { background: #eff3ff; box-shadow: inset 3px 0 0 #6366f1; }
.qm-member-row .qm-del { opacity: .45; transition: opacity .1s; }
.qm-member-row:hover .qm-del { opacity: 1; }
</style>