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>
186 lines
10 KiB
Vue
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>
|