Inbox identity/grouping (fixes mislabeled threads): - customerName = real From display name, then a customer matched BY EMAIL, then the raw address — never the AI's content-guessed name (a gilles@ relay showed as "Sylvie Juteau"). - never group a thread by one of our own domains (support@targo.ca, *@targointernet.com): re-ingested outbound was collapsing unrelated threads into one mislabeled "Guylaine Gagnon" thread. Refactor: queues/TYPES/QUEUE_OF/CAT centralized in lib/categories.js (were drifting across conversation.js + inbox-triage.js; telephonie/television had no matching triage type). Removed dead export findConversationByEmail. Feat: addMessage stamps msg.agent on outbound replies -> per-agent stats. Historique (/historique, HistoriquePage.vue): tournées par technicien (ALL statuses incl. Completed/Cancelled — the board hid them), tech leaderboard, inbox leaderboard. Hub: /dispatch/history, /dispatch/leaderboard, /conversations/leaderboard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
324 lines
23 KiB
Vue
324 lines
23 KiB
Vue
<template>
|
|
<div class="mlist" :class="{ 'mlist-compact': compact }">
|
|
<div class="mlist-head">
|
|
<q-input v-model="search" dense outlined clearable debounce="200" placeholder="Rechercher (nom, courriel, sujet…)" class="mlist-search">
|
|
<template #prepend><q-icon name="search" size="18px" /></template>
|
|
</q-input>
|
|
<q-btn flat dense round :icon="compact ? 'density_small' : 'density_medium'" size="sm" @click="toggleDensity"><q-tooltip>{{ compact ? 'Affichage confortable' : 'Affichage compact' }}</q-tooltip></q-btn>
|
|
<q-btn flat dense round :icon="notifyEnabled ? 'notifications_active' : 'notifications_none'" :color="notifyEnabled ? 'indigo-6' : 'grey-6'" size="sm" @click="toggleNotify"><q-tooltip>{{ notifyEnabled ? 'Notifications navigateur activées' : 'Activer les notifications de nouveaux courriels' }}</q-tooltip></q-btn>
|
|
<q-btn flat dense round icon="refresh" size="sm" :loading="loading" @click="fetchList" />
|
|
</div>
|
|
|
|
<!-- Case « tout cocher » + filtres OU actions de lot DANS LA MÊME LIGNE (hauteur constante → pas de décalage au clic) -->
|
|
<div class="mlist-tabs">
|
|
<q-checkbox dense size="xs" class="mlist-allcb" :model-value="headState" @update:model-value="toggleAll"><q-tooltip>{{ checked.size ? 'Tout décocher' : 'Tout cocher' }}</q-tooltip></q-checkbox>
|
|
<template v-if="checked.size">
|
|
<span class="text-weight-medium text-grey-8">{{ checked.size }} sélectionné(s)</span>
|
|
<q-space />
|
|
<q-btn dense flat no-caps size="sm" color="teal-7" icon="confirmation_number" label="→ Ticket" :loading="batchBusy">
|
|
<q-menu auto-close>
|
|
<q-list dense style="min-width:190px">
|
|
<q-item-label header class="q-py-xs">Créer un ticket dans…</q-item-label>
|
|
<q-item v-for="dep in TICKET_DEPTS" :key="dep.cat" clickable @click="batchTicket(dep)">
|
|
<q-item-section avatar><q-icon :name="dep.icon" :color="dep.color" size="18px" /></q-item-section>
|
|
<q-item-section>{{ dep.label }}</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-menu>
|
|
</q-btn>
|
|
<q-btn dense flat no-caps size="sm" color="grey-7" icon="archive" label="Archiver" :loading="batchBusy" @click="batchArchive" />
|
|
<q-btn dense flat no-caps size="sm" color="red-6" icon="delete" label="Supprimer" :loading="batchBusy" @click="batchDelete" />
|
|
<q-btn dense flat round size="sm" icon="close" @click="checked = new Set()"><q-tooltip>Annuler la sélection</q-tooltip></q-btn>
|
|
</template>
|
|
<template v-else>
|
|
<q-chip dense clickable size="sm" :color="sel.size === 0 ? 'indigo-6' : 'grey-3'" :text-color="sel.size === 0 ? 'white' : 'grey-8'" @click="toggle('all')">
|
|
Tous<span class="mlist-cnt">{{ visible.length }}</span>
|
|
</q-chip>
|
|
<q-chip v-for="f in chips" :key="f.key" dense clickable size="sm"
|
|
:color="sel.has(f.key) ? 'indigo-6' : 'grey-3'" :text-color="sel.has(f.key) ? 'white' : 'grey-8'"
|
|
@click="toggle(f.key)">
|
|
{{ f.label }}<span v-if="f.count" class="mlist-cnt">{{ f.count }}</span>
|
|
</q-chip>
|
|
</template>
|
|
</div>
|
|
|
|
<div class="mlist-body">
|
|
<div v-for="d in shown" :key="d.id" class="mrow" :class="{ 'mrow-unread': isUnread(d), 'mrow-checked': checked.has(d.id) }" @click="open(d)">
|
|
<q-checkbox :model-value="checked.has(d.id)" dense size="xs" class="mrow-cb" @click.stop @update:model-value="toggleCheck(d.id)" />
|
|
<!-- icône de canal seulement pour SMS/chat/Facebook ; pour le courriel (cas par défaut) elle est redondante -->
|
|
<q-icon v-if="d.channel && d.channel !== 'email'" :name="chanIcon(d.channel)" :color="chanColor(d.channel)" size="17px" class="mrow-chan" />
|
|
<!-- Ligne unique pleine largeur (style Gmail) : Nom · file · Sujet — aperçu · indicateurs · heure -->
|
|
<span class="mrow-name">{{ d.customerName || d.email || d.phone || 'Inconnu' }}</span>
|
|
<span v-if="replyInfo(d).count > 1" class="mrow-count">{{ replyInfo(d).count }}</span>
|
|
<q-badge v-if="d.queue" class="mrow-tag" color="blue-grey-1" text-color="blue-grey-8">{{ qLabel(d.queue) }}</q-badge>
|
|
<div class="mrow-subject"><span v-if="subj(d)" class="mrow-subj-strong">{{ subj(d) }}</span><span class="mrow-snip">{{ subj(d) && snip(d) ? ' — ' : '' }}{{ snip(d) }}</span></div>
|
|
<div class="mrow-right">
|
|
<q-icon v-if="replyInfo(d).client" name="reply" size="14px" color="teal-6" class="mrow-rep"><q-tooltip>Le client a répondu</q-tooltip></q-icon>
|
|
<q-icon v-if="replyInfo(d).team" name="forum" size="13px" color="indigo-5" class="mrow-rep"><q-tooltip>Un collègue a répondu</q-tooltip></q-icon>
|
|
<span v-if="typingLabel(d)" class="mrow-typing"><q-icon name="edit" size="11px" /> {{ typingLabel(d) }}</span>
|
|
<ReaderStack :readers="readersOf(d)" :me="meEmail" :max="3" :size="17" />
|
|
<span class="mrow-time">{{ relTime(lastTs(d)) }}</span>
|
|
</div>
|
|
<!-- Actions au survol (style Gmail) : répondre / classer en ticket par dépt / archiver / supprimer -->
|
|
<div class="mrow-actions" @click.stop>
|
|
<q-spinner v-if="rowBusy === d.id" size="18px" color="indigo-6" />
|
|
<template v-else>
|
|
<q-btn flat dense round size="sm" icon="reply" color="grey-7" @click.stop="open(d)"><q-tooltip>Répondre (ouvrir le fil)</q-tooltip></q-btn>
|
|
<q-btn flat dense round size="sm" icon="confirmation_number" color="teal-7">
|
|
<q-tooltip>Classer en ticket…</q-tooltip>
|
|
<q-menu auto-close anchor="bottom right" self="top right">
|
|
<q-list dense style="min-width:190px">
|
|
<q-item-label header class="q-py-xs">Créer un ticket dans…</q-item-label>
|
|
<q-item v-for="dep in TICKET_DEPTS" :key="dep.cat" clickable @click="ticketTo(d, dep)">
|
|
<q-item-section avatar><q-icon :name="dep.icon" :color="dep.color" size="18px" /></q-item-section>
|
|
<q-item-section>{{ dep.label }}</q-item-section>
|
|
</q-item>
|
|
</q-list>
|
|
</q-menu>
|
|
</q-btn>
|
|
<q-btn flat dense round size="sm" icon="filter_alt" color="blue-grey-6" @click.stop="openFilterLike(d)"><q-tooltip>Filtrer les messages comme celui-ci (masquer / afficher)…</q-tooltip></q-btn>
|
|
<q-btn flat dense round size="sm" icon="archive" color="grey-7" @click.stop="quickArchive(d)"><q-tooltip>Archiver (retirer de la boîte)</q-tooltip></q-btn>
|
|
<q-btn flat dense round size="sm" icon="delete" color="red-4" @click.stop="quickDelete(d)"><q-tooltip>Supprimer</q-tooltip></q-btn>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div v-if="!shown.length" class="mlist-empty">Aucune conversation{{ sel.size || search ? ' (filtre actif)' : '' }}</div>
|
|
</div>
|
|
|
|
<!-- « Filtrer les messages comme celui-ci » : crée une règle masquer/afficher (style Gmail) -->
|
|
<q-dialog v-model="filterDialog.open">
|
|
<q-card style="min-width:340px;max-width:92vw">
|
|
<q-card-section class="row items-center q-pb-none"><q-icon name="filter_alt" color="blue-grey-6" size="20px" class="q-mr-sm" /><span class="text-subtitle1 text-weight-bold">Filtrer les messages comme celui-ci</span></q-card-section>
|
|
<q-card-section>
|
|
<div class="text-caption text-grey-7 q-mb-sm">Les messages correspondants (futurs <b>et déjà reçus</b>) seront <b>{{ filterDialog.action === 'mask' ? 'masqués de la boîte' : 'toujours affichés' }}</b>.</div>
|
|
<q-option-group :model-value="filterDialog.field" @update:model-value="onFilterField" inline dense color="indigo-6" :options="[{ label: 'Expéditeur', value: 'from' }, { label: 'Sujet', value: 'subject' }]" />
|
|
<q-input dense outlined v-model="filterDialog.contains" class="q-mt-sm" :label="filterDialog.field === 'from' ? 'Expéditeur contient' : 'Sujet contient'" hint="Courriel complet, domaine (ex. targo.ca) ou mot-clé" />
|
|
<q-option-group v-model="filterDialog.action" inline dense color="indigo-6" class="q-mt-md" :options="[{ label: 'Masquer', value: 'mask' }, { label: 'Toujours afficher', value: 'allow' }]" />
|
|
</q-card-section>
|
|
<q-card-actions align="right">
|
|
<q-btn flat no-caps label="Annuler" v-close-popup />
|
|
<q-btn unelevated no-caps color="indigo-6" icon="check" label="Créer la règle" @click="doSaveFilter" />
|
|
</q-card-actions>
|
|
</q-card>
|
|
</q-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useQuasar } from 'quasar'
|
|
import { useConversations } from 'src/composables/useConversations'
|
|
import { useAuthStore } from 'src/stores/auth'
|
|
import { relTime } from 'src/composables/useFormatters'
|
|
import { useRouter } from 'vue-router'
|
|
import ReaderStack from './ReaderStack.vue'
|
|
|
|
const $q = useQuasar()
|
|
const router = useRouter()
|
|
const { discussions, QUEUES, fetchList, loading, openDiscussion, panelOpen, agentTyping, bulkDelete, bulkArchive, createTicket, archiveDiscussion, notifyEnabled, requestNotifyPermission, fetchInboxRules, saveInboxRules } = useConversations()
|
|
// « Filtrer comme ceci » (style Gmail) : crée une règle masquer/afficher par expéditeur ou sujet
|
|
const filterDialog = ref({ open: false, field: 'from', contains: '', action: 'mask', srcEmail: '', srcSubject: '' })
|
|
function openFilterLike (d) { filterDialog.value = { open: true, field: 'from', contains: d.email || d.customerName || '', action: 'mask', srcEmail: d.email || '', srcSubject: subj(d) || '' } }
|
|
function onFilterField (v) { filterDialog.value.field = v; filterDialog.value.contains = v === 'from' ? filterDialog.value.srcEmail : filterDialog.value.srcSubject }
|
|
async function doSaveFilter () {
|
|
const f = filterDialog.value
|
|
if (!String(f.contains || '').trim()) { $q.notify({ type: 'warning', message: 'Précisez un texte à filtrer.' }); return }
|
|
try {
|
|
const rules = await fetchInboxRules()
|
|
rules.push({ id: 'r' + Date.now(), field: f.field, contains: String(f.contains).trim(), action: f.action })
|
|
const r = await saveInboxRules(rules)
|
|
$q.notify({ type: 'positive', message: (f.action === 'mask' ? 'Règle : masquer' : 'Règle : toujours afficher') + ` « ${f.contains} »` + (r.retro ? ` · ${r.retro} conv. mise(s) à jour` : '') })
|
|
filterDialog.value.open = false
|
|
} catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
|
}
|
|
async function toggleNotify () {
|
|
if (notifyEnabled.value) { $q.notify({ type: 'info', message: 'Pour les désactiver, utilisez les réglages de notification du navigateur.' }); return }
|
|
const ok = await requestNotifyPermission()
|
|
$q.notify({ type: ok ? 'positive' : 'warning', message: ok ? 'Notifications activées — alerte à chaque nouveau courriel.' : 'Refusé — autorisez les notifications dans le navigateur.' })
|
|
}
|
|
const meEmail = computed(() => { try { return useAuthStore().user } catch { return null } })
|
|
|
|
const sel = ref(new Set()) // filtres cumulables (départements OU entre eux ; unread/mine = ET)
|
|
const search = ref('')
|
|
const compact = ref(false); try { compact.value = localStorage.getItem('mlist-density') === 'compact' } catch (e) { /* */ }
|
|
function toggleDensity () { compact.value = !compact.value; try { localStorage.setItem('mlist-density', compact.value ? 'compact' : '') } catch (e) { /* */ } }
|
|
const checked = ref(new Set()) // ids de discussions cochées (batch)
|
|
const batchBusy = ref(false)
|
|
|
|
const QLABELS = { Facturations: 'Facturation', 'Service à la clientèle': 'Service client', Supports: 'Support', Technicien: 'Technicien' }
|
|
function qLabel (q) { return QLABELS[q] || q }
|
|
function chanIcon (c) { return c === 'email' ? 'mail' : c === 'webchat' ? 'forum' : c === 'facebook' ? 'chat' : 'sms' }
|
|
function chanColor (c) { return c === 'email' ? 'red-5' : c === 'webchat' ? 'purple-5' : c === 'facebook' ? 'blue-6' : 'teal-6' }
|
|
function lastTs (d) { return d.lastMessage && d.lastMessage.ts }
|
|
function subjectOf (d) { return d.subject || (d.lastMessage && (d.lastMessage.text || '')) || '—' }
|
|
function subj (d) { const s = d.subject; return (s && s !== '(courriel)') ? s : '' } // ligne de sujet (vide pour SMS/chat)
|
|
function snip (d) { return String((d.lastMessage && d.lastMessage.text) || '').replace(/^✉️[^\n]*\n+/, '').replace(/\s+/g, ' ').trim() } // aperçu du dernier message
|
|
function discToken (d) { const a = (d.conversations || []).find(c => c.status === 'active'); return (a && a.token) || d.token || ((d.conversations || [])[0] || {}).token || null }
|
|
|
|
function isUnread (d) {
|
|
if (!d.lastMessage || d.lastMessage.from === 'agent') return false
|
|
const mine = (d.readBy || {})[meEmail.value]
|
|
if (!mine) return true
|
|
return !mine.lastTs || String(mine.lastTs) < String(d.lastMessage.ts)
|
|
}
|
|
function readersOf (d) {
|
|
const rb = d.readBy || {}
|
|
return Object.keys(rb).map(e => ({ email: e, readAt: rb[e] && rb[e].readAt })).sort((a, b) => String(b.readAt || '').localeCompare(String(a.readAt || '')))
|
|
}
|
|
function typingLabel (d) {
|
|
for (const c of (d.conversations || [])) { const a = agentTyping.value[c.token]; if (a) return a.includes('@') ? a.split('@')[0] : a }
|
|
return ''
|
|
}
|
|
// Indicateurs « il y a eu des réponses » : nb total de messages + un collègue (agent) a répondu + le client a répondu (≥2 msgs client)
|
|
function replyInfo (d) {
|
|
const ms = d.messages || []
|
|
return { count: d.messageCount || ms.length, team: ms.some(m => m.from === 'agent'), client: ms.filter(m => m.from === 'customer').length > 1 }
|
|
}
|
|
function matchSearch (d, q) {
|
|
return [d.customerName, d.email, d.phone, d.subject, d.lastMessage && d.lastMessage.text].some(x => String(x || '').toLowerCase().includes(q))
|
|
}
|
|
|
|
const CH_LABELS = { email: 'Courriel', sms: 'SMS', webchat: 'Chat web', facebook: 'Messenger' }
|
|
// Vue « Masqués » : affiche les conversations masquées (noise) au lieu de l'inbox normale
|
|
const maskedMode = computed(() => sel.value.has('masked'))
|
|
const visible = computed(() => (discussions.value || []).filter(d => d.status === 'active' && (maskedMode.value ? !!d.noise : !d.noise)))
|
|
const maskedCount = computed(() => (discussions.value || []).filter(d => d.status === 'active' && d.noise).length)
|
|
const chips = computed(() => {
|
|
const base = [
|
|
{ key: 'unread', label: 'Non lus', count: visible.value.filter(isUnread).length },
|
|
{ key: 'mine', label: 'À moi', count: visible.value.filter(d => d.assignee === meEmail.value).length },
|
|
]
|
|
for (const q of QUEUES) base.push({ key: q, label: qLabel(q), count: visible.value.filter(d => (d.queue || '') === q).length })
|
|
// Filtre par CANAL — seulement si plusieurs canaux présents (sinon inutile dans une boîte tout-courriel)
|
|
const chans = [...new Set(visible.value.map(d => d.channel || 'email'))]
|
|
if (chans.length > 1) for (const ch of chans) base.push({ key: 'ch:' + ch, label: CH_LABELS[ch] || ch, count: visible.value.filter(d => (d.channel || 'email') === ch).length })
|
|
if (maskedCount.value) base.push({ key: 'masked', label: '🔇 Masqués', count: maskedCount.value }) // voir ce qui est filtré/masqué
|
|
return base
|
|
})
|
|
function toggle (k) {
|
|
const s = new Set(sel.value)
|
|
if (k === 'all') s.clear()
|
|
else if (s.has(k)) s.delete(k); else s.add(k)
|
|
sel.value = s
|
|
}
|
|
const shown = computed(() => {
|
|
const q = search.value.trim().toLowerCase()
|
|
const depts = [...sel.value].filter(k => QUEUES.includes(k))
|
|
const chans = [...sel.value].filter(k => k.startsWith('ch:')).map(k => k.slice(3))
|
|
const needU = sel.value.has('unread'); const needM = sel.value.has('mine')
|
|
return visible.value.filter(d => {
|
|
if (depts.length && !depts.includes(d.queue || '')) return false
|
|
if (chans.length && !chans.includes(d.channel || 'email')) return false
|
|
if (needU && !isUnread(d)) return false
|
|
if (needM && d.assignee !== meEmail.value) return false
|
|
if (q && !matchSearch(d, q)) return false
|
|
return true
|
|
}).sort((a, b) => String(lastTs(b) || '').localeCompare(String(lastTs(a) || '')))
|
|
})
|
|
|
|
function toggleCheck (id) { const s = new Set(checked.value); s.has(id) ? s.delete(id) : s.add(id); checked.value = s }
|
|
const checkedDiscs = computed(() => shown.value.filter(d => checked.value.has(d.id)))
|
|
// Case maîtresse « tout cocher » : true=tout, null=indéterminé (partiel), false=rien
|
|
const allChecked = computed(() => shown.value.length > 0 && shown.value.every(d => checked.value.has(d.id)))
|
|
const headState = computed(() => checked.value.size ? (allChecked.value ? true : null) : false)
|
|
function toggleAll () { if (allChecked.value) { checked.value = new Set() } else { const s = new Set(); shown.value.forEach(d => s.add(d.id)); checked.value = s } }
|
|
async function batchDelete () {
|
|
if (!checkedDiscs.value.length) return
|
|
$q.dialog({ title: 'Supprimer', message: `Supprimer ${checkedDiscs.value.length} conversation(s) ? (courriels liés → corbeille Gmail)`, cancel: true, ok: { label: 'Supprimer', color: 'negative' } }).onOk(async () => {
|
|
batchBusy.value = true
|
|
try { await bulkDelete(checkedDiscs.value); $q.notify({ type: 'positive', message: 'Supprimé.' }); checked.value = new Set() } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { batchBusy.value = false }
|
|
})
|
|
}
|
|
async function batchTicket (dep) {
|
|
const list = checkedDiscs.value.slice()
|
|
if (!list.length) return
|
|
batchBusy.value = true
|
|
let ok = 0
|
|
for (const d of list) {
|
|
const tok = discToken(d); if (!tok) continue
|
|
try { await createTicket(tok, { title: subjectOf(d).slice(0, 90), category: dep.cat, priority: 'Medium' }); ok++ } catch (e) { /* continue */ }
|
|
}
|
|
try { await bulkArchive(list) } catch (e) { /* */ } // retire de la boîte ; les tickets suivent le travail
|
|
batchBusy.value = false
|
|
checked.value = new Set()
|
|
$q.notify({ type: ok ? 'positive' : 'negative', message: `${ok} ticket(s) « ${dep.label} » créé(s) · conversations archivées.` })
|
|
}
|
|
async function batchArchive () {
|
|
const list = checkedDiscs.value.slice()
|
|
if (!list.length) return
|
|
batchBusy.value = true
|
|
try { await bulkArchive(list); $q.notify({ type: 'positive', message: `${list.length} archivé(s).` }); checked.value = new Set() } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { batchBusy.value = false }
|
|
}
|
|
|
|
// Actions au survol (style Gmail) : répondre / classer en ticket par département / archiver / supprimer
|
|
const TICKET_DEPTS = [
|
|
{ label: 'Support technique', cat: 'Support', icon: 'build', color: 'indigo-6' },
|
|
{ label: 'Facturation', cat: 'Facturation', icon: 'receipt_long', color: 'green-7' },
|
|
{ label: 'Service client', cat: 'Service client', icon: 'support_agent', color: 'blue-6' },
|
|
{ label: 'Installation', cat: 'Installation', icon: 'cable', color: 'deep-orange-6' },
|
|
]
|
|
const rowBusy = ref(null)
|
|
async function ticketTo (d, dep) {
|
|
const tok = discToken(d); if (!tok) return
|
|
rowBusy.value = d.id
|
|
try { await createTicket(tok, { title: subjectOf(d).slice(0, 90), category: dep.cat, priority: 'Medium' }); await archiveDiscussion(d); $q.notify({ type: 'positive', message: 'Ticket « ' + dep.label + ' » créé — conversation archivée' }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { rowBusy.value = null }
|
|
}
|
|
async function quickArchive (d) {
|
|
rowBusy.value = d.id
|
|
try { await archiveDiscussion(d); $q.notify({ type: 'positive', message: 'Archivé' }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) } finally { rowBusy.value = null }
|
|
}
|
|
function quickDelete (d) {
|
|
$q.dialog({ title: 'Supprimer', message: 'Supprimer cette conversation ? (courriels liés → corbeille Gmail)', cancel: true, ok: { label: 'Supprimer', color: 'negative' } }).onOk(async () => {
|
|
try { await bulkDelete([d]); $q.notify({ type: 'positive', message: 'Supprimé' }) } catch (e) { $q.notify({ type: 'negative', message: e.message }) }
|
|
})
|
|
}
|
|
function open (d) { const tok = discToken(d); if (tok) router.push('/communications/c/' + tok); else { panelOpen.value = true; openDiscussion(d) } }
|
|
onMounted(() => { if (!discussions.value || !discussions.value.length) fetchList() })
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Pleine hauteur ; largeur PLAFONNÉE à une taille confortable (ni étriquée, ni étirée à l'infini sur grand écran), ancrée à gauche */
|
|
.mlist { display: flex; flex-direction: column; height: calc(100vh - 50px); max-width: 1280px; }
|
|
.mlist-head { display: flex; align-items: center; gap: 6px; padding: 8px 12px 4px; }
|
|
.mlist-search { flex: 1; max-width: 520px; }
|
|
/* Une SEULE ligne (les puces défilent horizontalement si trop nombreuses) → hauteur constante, donc la sélection ne décale jamais la liste */
|
|
.mlist-tabs { display: flex; flex-wrap: nowrap; align-items: center; gap: 4px; padding: 6px 12px; border-bottom: 1px solid var(--ops-border, #e2e8f0); overflow-x: auto; min-height: 40px; }
|
|
.mlist-tabs::-webkit-scrollbar { height: 0; }
|
|
.mlist-tabs .q-chip { flex: 0 0 auto; }
|
|
.mlist-allcb { flex: 0 0 auto; margin-right: 4px; }
|
|
.mlist-cnt { margin-left: 5px; font-size: 0.66rem; opacity: 0.8; }
|
|
.mlist-batch { display: flex; align-items: center; gap: 4px; padding: 6px 12px; background: #eef2ff; border-bottom: 1px solid #c7d2fe; font-size: 0.82rem; }
|
|
.mlist-body { flex: 1; overflow-y: auto; overflow-x: hidden; }
|
|
/* garantit que chaque ligne tient dans la largeur (pas de débordement horizontal ; le sujet/aperçu s'ellipse) */
|
|
.mrow { width: 100%; box-sizing: border-box; }
|
|
.mrow { display: flex; gap: 8px; padding: 8px 16px; border-bottom: 1px solid #f1f5f9; cursor: pointer; align-items: center; position: relative; }
|
|
.mrow:hover { background: var(--ops-bg-hover, #eef2ff); }
|
|
/* Actions au survol : apparaissent à droite (par-dessus heure/lecteurs), comme Gmail */
|
|
.mrow-actions { position: absolute; right: 8px; top: 50%; transform: translateY(-50%); display: none; align-items: center; gap: 1px; background: var(--ops-bg-hover, #eef2ff); padding: 2px 5px; border-radius: 8px; box-shadow: 0 1px 5px rgba(15, 23, 42, .12); }
|
|
.mrow:hover .mrow-actions { display: flex; }
|
|
.mrow-checked { background: #eef2ff; }
|
|
.mrow-cb { margin-top: 1px; flex: 0 0 auto; }
|
|
.mrow-chan { margin-top: 2px; flex: 0 0 auto; }
|
|
.mrow-main { flex: 1; min-width: 0; }
|
|
.mrow-line1 { display: flex; align-items: center; gap: 6px; }
|
|
.mlist-compact .mrow { padding-top: 5px; padding-bottom: 5px; }
|
|
.mlist-compact .mrow-subject, .mlist-compact .mrow-name { font-size: 0.76rem; }
|
|
.mrow-name { flex: 0 0 auto; width: 200px; max-width: 26%; min-width: 110px; font-size: 0.86rem; color: var(--ops-text, #1e293b); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
/* Sujet — aperçu : occupe toute la largeur restante, sur une seule ligne */
|
|
.mrow-subject { flex: 1 1 auto; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 0.82rem; }
|
|
.mrow-subj-strong { color: var(--ops-text, #1e293b); }
|
|
.mrow-snip { color: var(--ops-text-muted, #64748b); }
|
|
.mrow-right { flex: 0 0 auto; display: flex; align-items: center; gap: 6px; }
|
|
.mrow-tag { font-size: 0.62rem; }
|
|
.mrow-count { font-size: 0.66rem; color: #64748b; background: #eef2f7; border-radius: 9px; padding: 0 6px; line-height: 1.5; flex: 0 0 auto; }
|
|
.mrow-rep { margin-left: 4px; flex: 0 0 auto; }
|
|
.mrow-time { font-size: 0.7rem; color: var(--ops-text-muted, #64748b); flex: 0 0 auto; }
|
|
.mrow-sub { font-size: 0.78rem; color: var(--ops-text-muted, #64748b); margin-top: 1px; }
|
|
.mrow-foot { display: flex; align-items: center; min-height: 20px; margin-top: 5px; }
|
|
.mrow-typing { font-size: 0.7rem; color: #0d9488; font-weight: 500; display: flex; align-items: center; gap: 3px; }
|
|
.mrow-unread .mrow-name, .mrow-unread .mrow-subj-strong { font-weight: 700; }
|
|
.mrow-unread .mrow-snip { color: var(--ops-text, #334155); }
|
|
.mrow-unread { background: #fafbff; }
|
|
.mlist-empty { text-align: center; color: #94a3b8; padding: 32px; font-size: 0.85rem; }
|
|
</style>
|