gigafibre-fsm/apps/ops/src/layouts/MainLayout.vue
louispaulb 14530787bb consolidate uncommitted multi-session work (NL staff-agent, user avatars/profil, HelpHint, ticket detail modules, skill icons, hub agent/payments/sync)
Lot de travaux accumulés non commités (plusieurs sessions), vérifiés : hub `node --check`
tout OK + build SPA propre.
- ops : AssignmentField, CommandPalette, IssueDetail/DetailModal + detail-sections/modules,
  UserAvatar/useAvatar, UserProfileDialog/EmployeeEditDialog, HelpHint, useSkillIcons,
  pages (Dashboard/Equipe/Clients/Tickets/Reports/Settings/Evaluations/LegacySync…),
  usePermissions/useUserGroups/useDetailModal, MainLayout, TicketStatusControl
- hub : staff-agent.js (couche commandes NL), agent.js/agent-tools.json/voice-agent.js,
  avatars.js, conversation.js, payments.js, sync-orchestrator.js, auth.js, campaigns.js,
  legacy-dispatch-sync.js, server.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:43:50 -04:00

414 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<q-layout view="lHh LpR fFf">
<!-- Collapsible Sidebar -->
<q-drawer v-model="drawer" :width="sidebarW" :breakpoint="1024" class="ops-sidebar" :class="{ 'ops-sidebar-mini': collapsed }">
<q-list>
<q-item class="q-py-md q-mb-sm" style="pointer-events:none" :class="{ 'justify-center': collapsed }">
<q-item-section avatar><q-icon name="hub" size="28px" color="white" /></q-item-section>
<q-item-section v-if="!collapsed"><q-item-label style="color:#fff;font-size:1.1rem;font-weight:700">Targo Ops</q-item-label></q-item-section>
</q-item>
<!-- RÉDUIT (rail d'icônes) : liste à plat, sans sections -->
<template v-if="collapsed">
<q-item v-for="nav in navItems" :key="nav.path" clickable :to="nav.path" class="justify-center"
:class="{ 'active-link': isActive(nav.path) }">
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
<q-tooltip anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
</q-item>
</template>
<!-- DÉPLOYÉ : épinglés en haut + sections repliables (menu plus court) -->
<template v-else>
<q-item v-for="nav in topItems" :key="nav.path" clickable :to="nav.path" :class="{ 'active-link': isActive(nav.path) }">
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
<q-item-section><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
<q-item-section side v-if="nav.badge"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
</q-item>
<q-expansion-item v-for="sec in shownSections" :key="sec.key" dense expand-separator
:model-value="!!openSections[sec.key]" @update:model-value="v => setSection(sec.key, v)"
header-class="ops-sec-header">
<template #header>
<q-item-section avatar><component :is="icons[sec.icon]" :size="18" /></q-item-section>
<q-item-section><q-item-label>{{ sec.label }}</q-item-label></q-item-section>
</template>
<q-item v-for="nav in itemsOf(sec.key)" :key="nav.path" clickable :to="nav.path" class="ops-sub-item"
:class="{ 'active-link': isActive(nav.path) }">
<q-item-section avatar><component :is="icons[nav.icon]" :size="18" /></q-item-section>
<q-item-section><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
<q-item-section side v-if="nav.badge"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
</q-item>
</q-expansion-item>
</template>
</q-list>
<div class="ops-sidebar-bottom">
<q-item dense clickable @click="toggleCollapse" class="ops-collapse-btn" :class="{ 'justify-center': collapsed }">
<q-item-section avatar>
<component :is="collapsed ? icons.PanelLeftOpen : icons.PanelLeftClose" :size="16" />
</q-item-section>
<q-item-section v-if="!collapsed"><q-item-label>Réduire</q-item-label></q-item-section>
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Agrandir le menu</q-tooltip>
</q-item>
<q-item dense clickable @click="profileOpen = true" :class="{ 'justify-center': collapsed }">
<q-item-section avatar><UserAvatar :email="userEmail" :name="userName" :size="collapsed ? 22 : 24" /></q-item-section>
<q-item-section v-if="!collapsed"><q-item-label>{{ userName || auth.user || 'User' }}</q-item-label><q-item-label caption>Mon profil</q-item-label></q-item-section>
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Mon profil</q-tooltip>
</q-item>
<q-item dense clickable @click="auth.doLogout()" :class="{ 'justify-center': collapsed }">
<q-item-section avatar><component :is="icons.LogOut" :size="16" /></q-item-section>
<q-item-section v-if="!collapsed"><q-item-label>Déconnexion</q-item-label></q-item-section>
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">Déconnexion</q-tooltip>
</q-item>
</div>
</q-drawer>
<!-- Mobile header -->
<q-header v-if="$q.screen.lt.lg" class="ops-mobile-header">
<q-toolbar>
<q-btn flat round dense icon="menu" color="white" @click="drawer = !drawer" />
<q-toolbar-title class="text-weight-bold" style="font-size:1rem;color:#fff">{{ currentNav?.label || 'Targo Ops' }}</q-toolbar-title>
<q-space />
<NotificationBell v-if="can('view_clients')" dark />
<q-btn v-if="!isDispatch" flat round dense icon="search" color="white" @click="mobileSearchOpen = !mobileSearchOpen" />
<q-btn flat round dense @click="profileOpen = true">
<UserAvatar :email="userEmail" :name="userName" :size="26" />
</q-btn>
</q-toolbar>
<div v-if="mobileSearchOpen && !isDispatch" class="q-px-sm q-pb-sm" style="background:var(--ops-sidebar-bg)">
<q-input v-model="searchQuery" placeholder="Rechercher client, adresse..." dense outlined dark autofocus class="ops-search-dark"
@keydown.enter.prevent="doSearch" @keydown.escape="closeMobileSearch">
<template #prepend><q-icon name="search" color="grey-5" /></template>
<template #append v-if="searchQuery"><q-icon name="close" class="cursor-pointer" color="grey-5" @click="clearSearch" /></template>
</q-input>
<div v-if="searchResults.length" class="ops-search-results">
<div v-for="r in searchResults" :key="r.id" class="ops-search-result" @mousedown="goToResult(r)">
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'green-5' : 'teal-5'" class="q-mr-sm" />
<div style="flex:1;min-width:0">
<div class="ops-search-title">{{ r.title }}</div>
<div class="ops-search-sub">{{ r.sub }}</div>
</div>
</div>
</div>
</div>
</q-header>
<!-- Main content -->
<q-page-container>
<!-- Desktop top bar (hidden on dispatch) -->
<div v-if="$q.screen.gt.md && !isDispatch" class="ops-topbar">
<div class="text-h6 text-weight-bold">{{ currentNav?.label || '' }}</div>
<q-space />
<div style="position:relative;width:400px">
<q-input v-model="searchQuery" placeholder="Rechercher client, adresse, ticket..." dense outlined class="ops-search"
@keydown.enter.prevent="doSearch" @keydown.escape="clearSearch"
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
@update:model-value="onSearchInput" @blur="onSearchBlur">
<template #prepend><q-icon name="search" color="grey-6" /></template>
<template #append>
<q-icon v-if="searchQuery" name="close" class="cursor-pointer" @click="clearSearch" />
<div v-else class="ops-kbd-hint cursor-pointer" @click="openPalette">
<q-tooltip>Palette de commandes (⌘K)</q-tooltip>⌘K
</div>
</template>
</q-input>
<div v-if="searchResults.length && searchDropdownOpen" class="ops-search-results ops-search-results-desktop">
<div v-for="(r, i) in searchResults" :key="r.id" class="ops-search-result"
:class="{ 'ops-search-highlighted': i === highlightIdx }"
@mousedown="goToResult(r)">
<q-icon :name="r.icon" size="18px" :color="r.type === 'customer' ? 'green-5' : 'teal-5'" class="q-mr-sm" />
<div style="flex:1;min-width:0">
<div class="ops-search-title">{{ r.title }}</div>
<div class="ops-search-sub">{{ r.sub }}</div>
</div>
<div class="ops-search-type">{{ r.typeLabel }}</div>
</div>
</div>
<div v-else-if="searchDropdownOpen && searchQuery.length >= 2 && !searchResults.length && !searchLoading" class="ops-search-results ops-search-results-desktop">
<div class="ops-search-result" style="justify-content:center;color:#94a3b8">Aucun résultat</div>
</div>
</div>
<NotificationBell v-if="can('view_clients')" class="q-ml-sm" />
<q-btn flat round dense class="q-ml-sm" @click="profileOpen = true">
<UserAvatar :email="userEmail" :name="userName" :size="30" tooltip />
</q-btn>
</div>
<router-view />
</q-page-container>
<!-- Conversation panel (right drawer) — masqué sur la route plein écran (sinon double affichage du fil) -->
<ConversationPanel v-if="can('view_clients') && !route.path.startsWith('/communications/c/')" />
<!-- Softphone GLOBAL (Twilio WebRTC + repli SIP) : monté UNE fois, piloté par useSoftphone depuis ConversationPanel / la fiche. Remplace le montage exclusif dans ChatterPanel. -->
<PhoneModal v-if="can('view_clients')" v-model="spOpen" :initial-number="spNumber" :customer-name="spCustomerName" :customer-erp-name="spCustomer" :provider="spProvider" @call-ended="spHandleCallEnded" />
<!-- FAB messagerie : clic « forum » = ouvrir les communications (action directe et fiable) ; bouton « + » (clic) = menu créer rapide. -->
<q-page-sticky v-if="can('view_clients')" position="bottom-right" :offset="[18, 18]">
<div class="row items-center q-gutter-xs">
<!-- Menu « créer rapide » : POPUP composité (q-menu) au lieu d'une colonne à hauteur animée (q-slide-transition) → plus de saccade / reflow du page-sticky. -->
<q-btn round unelevated color="blue-grey-5" icon="add" size="sm">
<q-tooltip>Créer rapidement (ticket · texto · courriel · dicter)</q-tooltip>
<q-menu anchor="top right" self="bottom right" auto-close>
<q-list dense style="min-width:212px">
<q-item clickable v-close-popup @click="orchestratorOpen = true"><q-item-section avatar><q-icon name="bolt" color="deep-purple-6" /></q-item-section><q-item-section>Dicter une commande</q-item-section></q-item>
<q-item clickable v-close-popup @click="serviceStatusOpen = true"><q-item-section avatar><q-icon name="wifi_find" color="info" /></q-item-section><q-item-section>Statut du service</q-item-section></q-item>
<q-item clickable v-close-popup @click="goCreate"><q-item-section avatar><q-icon name="add_box" color="positive" /></q-item-section><q-item-section>Créer<q-item-label caption>tâche · intervention · soumission</q-item-label></q-item-section></q-item>
<q-item clickable v-close-popup @click="openNew('sms')"><q-item-section avatar><q-icon name="sms" color="green-6" /></q-item-section><q-item-section>Nouveau texto</q-item-section></q-item>
<q-item clickable v-close-popup @click="openNew('email')"><q-item-section avatar><q-icon name="mail" color="negative" /></q-item-section><q-item-section>Nouveau courriel</q-item-section></q-item>
</q-list>
</q-menu>
</q-btn>
<q-btn fab icon="forum" color="primary" @click="openComms">
<q-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
<q-tooltip>Ouvrir les communications (tous les fils · courriel · SMS · chat)</q-tooltip>
</q-btn>
</div>
</q-page-sticky>
<!-- Commande dictée / langage naturel → orchestre plusieurs actions (comms : tickets/SMS/courriel) -->
<OrchestratorDialog v-if="can('view_clients')" v-model="orchestratorOpen" />
<!-- ⌘K langage naturel → copilote STAFF dispatch/planification (function-calling ; plan → confirmer → exécuter).
MÊME composant réutilisé, branché sur le endpoint STAFF avec l'identité de l'agent (permissions serveur). -->
<OrchestratorDialog v-if="can('view_clients')" v-model="staffCmdOpen" :initial-text="staffCmdText"
title="Assistant Ops" plan-endpoint="/staff-agent" run-endpoint="/staff-agent/run" send-identity />
<!-- Statut service+modem par identifiant libre (adresse / nom / téléphone / courriel) — lecture -->
<ServiceStatusDialog v-if="can('view_clients')" v-model="serviceStatusOpen" />
<!-- Tampon d'envoi : courriels multi-destinataires retenus + compte à rebours + annuler (anti-spam) -->
<OutboxPanel v-if="can('view_clients')" />
<!-- Global Flow Editor dialog (any page can open it via useFlowEditor) -->
<FlowEditorDialog v-if="can('manage_settings')" />
<!-- Palette de commandes globale (Cmd/Ctrl+K) : actions + pages + actions de page + clients/équipe + langage naturel -->
<CommandPalette v-if="can('view_clients')" @nl="onNlCommand" />
<UserProfileDialog v-model="profileOpen" />
</q-layout>
</template>
<script setup>
import { ref, computed, nextTick, watch, defineAsyncComponent, onMounted, onBeforeUnmount } from 'vue'
import { useQuasar } from 'quasar'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from 'src/stores/auth'
import { usePermissions } from 'src/composables/usePermissions'
import { HUB_URL } from 'src/config/hub'
import { navItems as allNavItems, navSections } from 'src/config/nav'
import {
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper,
} from 'lucide-vue-next'
// Composants lourds montés conditionnellement (v-if) → chargés à la demande (defineAsyncComponent) au lieu d'alourdir
// le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref → sûr. NotificationBell reste synchrone (barre du haut, toujours visible).
const ConversationPanel = defineAsyncComponent(() => import('src/components/shared/ConversationPanel.vue'))
const PhoneModal = defineAsyncComponent(() => import('src/components/customer/PhoneModal.vue'))
import { useSoftphone } from 'src/composables/useSoftphone'
import NotificationBell from 'src/components/shared/NotificationBell.vue'
import UserAvatar from 'src/components/shared/UserAvatar.vue'
import UserProfileDialog from 'src/components/shared/UserProfileDialog.vue'
import { useConversations } from 'src/composables/useConversations'
import { useCreateSignal } from 'src/composables/useCreateSignal'
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
const OrchestratorDialog = defineAsyncComponent(() => import('src/components/shared/OrchestratorDialog.vue'))
const ServiceStatusDialog = defineAsyncComponent(() => import('src/components/shared/ServiceStatusDialog.vue'))
const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/OutboxPanel.vue'))
const CommandPalette = defineAsyncComponent(() => import('src/components/shared/CommandPalette.vue'))
import { useCommandPalette } from 'src/composables/useCommandPalette'
const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper }
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
const { requestCreate } = useCreateSignal()
const { open: spOpen, number: spNumber, customer: spCustomer, customerName: spCustomerName, provider: spProvider, handleCallEnded: spHandleCallEnded } = useSoftphone()
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
// FAB « forum » → le MODULE communications complet (page qui défile, tous les fils/SMS/chat) plutôt que le tiroir étroit qui ne scrollait pas.
function openComms () { panelOpen.value = false; if (!route.path.startsWith('/communications')) router.push('/communications') }
// FAB « Créer » → le chooser unifié de la Planification (tâche · intervention · soumission), où vivent les flux de création.
function goCreate () { requestCreate(); if (!route.path.startsWith('/planification')) router.push('/planification') }
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
const orchestratorOpen = ref(false)
const serviceStatusOpen = ref(false)
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
// ⌘K → langage naturel : ouvre le copilote STAFF (dispatch/planif) pré-rempli avec la requête tapée.
const staffCmdOpen = ref(false)
const staffCmdText = ref('')
function onNlCommand (text) { staffCmdText.value = text || ''; staffCmdOpen.value = true }
// ── Palette de commandes globale (Cmd/Ctrl+K) — escape hatch pour tout ce qu'on déclutter ──
const { togglePalette, openPalette } = useCommandPalette()
function onGlobalKey (e) {
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { e.preventDefault(); togglePalette() }
}
onMounted(() => window.addEventListener('keydown', onGlobalKey))
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKey))
const auth = useAuthStore()
const { can, isLoaded, userName, userEmail } = usePermissions()
const profileOpen = ref(false)
// Filter nav items based on user capabilities
const navItems = computed(() =>
allNavItems.filter(n => !n.requires || can(n.requires))
)
const route = useRoute()
const router = useRouter()
const $q = useQuasar()
// ── Menu en sous-menus (trop long → sections repliables) : items épinglés (sans `section`) + sections ──
const topItems = computed(() => navItems.value.filter(n => !n.section))
const shownSections = computed(() => navSections.filter(s => navItems.value.some(n => n.section === s.key)))
function itemsOf (key) { return navItems.value.filter(n => n.section === key) }
const openSections = ref((() => {
let saved = {}; try { saved = JSON.parse(localStorage.getItem('ops-nav-sections') || '{}') } catch (e) { /* */ }
const act = navItems.value.find(n => n.section && isActive(n.path))
if (act) saved[act.section] = true // ouvre toujours la section de la page courante
else if (!Object.keys(saved).length) saved.service = true // défaut 1re visite
return saved
})())
function setSection (key, v) { openSections.value = { ...openSections.value, [key]: v }; try { localStorage.setItem('ops-nav-sections', JSON.stringify(openSections.value)) } catch (e) { /* */ } }
// Sur desktop (≥ breakpoint 1024) le tiroir est persistant/ouvert ; sur mobile il démarre FERMÉ (overlay via hamburger).
const drawer = ref($q.screen.width >= 1024)
// Mobile : refermer le tiroir après navigation pour libérer l'écran.
watch(() => route.path, () => { if ($q.screen.width < 1024) drawer.value = false })
const collapsed = ref(localStorage.getItem('ops-sidebar-collapsed') !== 'false')
const sidebarW = computed(() => collapsed.value ? 64 : 220)
const isDispatch = computed(() => route.path === '/dispatch')
const currentNav = computed(() =>
navItems.value.find(n => n.path === route.path) || navItems.value.find(n => route.path.startsWith(n.path) && n.path !== '/')
)
function isActive (path) {
if (path === '/') return route.path === '/'
return route.path === path || route.path.startsWith(path + '/')
}
function toggleCollapse () {
collapsed.value = !collapsed.value
localStorage.setItem('ops-sidebar-collapsed', collapsed.value ? 'true' : 'false')
}
// ── Simple inline search (no composable, no external state) ──────────────
const searchQuery = ref('')
const searchResults = ref([])
const searchLoading = ref(false)
const searchDropdownOpen = ref(false)
const highlightIdx = ref(-1)
const mobileSearchOpen = ref(false)
let searchTimer = null
function onSearchInput (val) {
clearTimeout(searchTimer)
highlightIdx.value = -1
if (!val || val.length < 2) {
searchResults.value = []
searchDropdownOpen.value = false
searchLoading.value = false
return
}
searchDropdownOpen.value = true
searchLoading.value = true
searchTimer = setTimeout(() => runSearch(val), 300)
}
async function runSearch (q) {
if (!q || q.length < 2) { searchLoading.value = false; return }
try {
// Recherche FLOUE (pg_trgm, typo-tolérante) + multi-champs (nom / courriel / téléphone / adresse) via le hub.
// Remplace les 4 requêtes listDocs sous-chaîne → cohérent avec les autres sélecteurs de contact.
// team=1 → membres de l'équipe (System Users targo) EN PREMIER : « michel » sort Michel Blais sans préciser. Clic collègue = nouveau courriel.
const r = await fetch(`${HUB_URL}/collab/customer-search?q=${encodeURIComponent(q)}&team=1`)
const matches = r.ok ? ((await r.json()).matches || []) : []
searchResults.value = matches.slice(0, 12).map(m => m.kind === 'équipe' ? ({
id: 't-' + m.name, type: 'team', typeLabel: 'Équipe', icon: 'groups',
title: m.customer_name || m.name, sub: 'Membre de léquipe · ' + m.email, email: m.email, route: null,
}) : ({
id: 'c-' + m.name, type: 'customer', typeLabel: 'Client',
icon: m.matched === 'adresse' ? 'location_on' : m.matched === 'téléphone' ? 'call' : m.matched === 'courriel' ? 'mail' : 'person',
title: m.customer_name || m.name,
sub: [m.matched, m.address, m.email].filter(Boolean).join(' · ') || m.name,
route: '/clients/' + m.name,
}))
} catch {
searchResults.value = []
}
searchLoading.value = false
}
function moveHighlight (dir) {
if (!searchResults.value.length) return
searchDropdownOpen.value = true
highlightIdx.value = Math.max(-1, Math.min(searchResults.value.length - 1, highlightIdx.value + dir))
}
async function doSearch () {
const q = searchQuery.value?.trim()
if (!q || q.length < 2) return
// Always cancel debounce and run search immediately on Enter
if (!searchResults.value.length) {
clearTimeout(searchTimer)
searchLoading.value = true
await runSearch(q)
}
if (searchResults.value.length) {
const idx = highlightIdx.value >= 0 ? highlightIdx.value : 0
goToResult(searchResults.value[idx])
} else {
router.push({ path: '/clients', query: { q } })
clearSearch()
}
}
async function goToResult (r) {
if (r.type === 'team') { // collègue → nouveau courriel pré-rempli (le panneau de compo est monté dans ce layout)
clearSearch()
// Sur la page pleine conv le panneau n'est pas monté : on y navigue + attend le montage pour que le watch consomme le pré-remplissage.
if (route.path.startsWith('/communications/c/')) { await router.push('/communications'); await nextTick() }
openComposeDraft({ to: r.email, channel: 'email' })
return
}
router.push(r.route)
clearSearch()
}
function clearSearch () {
searchQuery.value = ''
searchResults.value = []
searchDropdownOpen.value = false
searchLoading.value = false
highlightIdx.value = -1
mobileSearchOpen.value = false
clearTimeout(searchTimer)
}
function closeMobileSearch () {
clearSearch()
mobileSearchOpen.value = false
}
function onSearchBlur () {
setTimeout(() => { searchDropdownOpen.value = false }, 200)
}
</script>
<style>
/* Indice ⌘K dans la barre de recherche desktop → ouvre la palette de commandes */
.ops-kbd-hint { font-size: 0.68rem; font-weight: 600; color: var(--ops-text-muted, #94a3b8); border: 1px solid var(--ops-border, #e2e8f0); border-radius: 6px; padding: 1px 6px; line-height: 1.4; white-space: nowrap; }
.ops-kbd-hint:hover { color: var(--ops-text, #334155); border-color: var(--ops-text-muted, #94a3b8); }
/* Menu en sous-menus (sidebar foncée) : entêtes de section + sous-items lisibles sur fond sombre */
.ops-sidebar .ops-sec-header { min-height: 38px; }
.ops-sidebar .ops-sec-header .q-item__label { color: rgba(255,255,255,.55); font-size: .72rem; letter-spacing: .03em; text-transform: uppercase; font-weight: 700; }
.ops-sidebar .ops-sec-header .q-item__section--avatar { color: rgba(255,255,255,.6); min-width: 34px; }
.ops-sidebar .q-expansion-item__toggle-icon { color: rgba(255,255,255,.45); }
.ops-sidebar .ops-sub-item { padding-left: 24px; min-height: 38px; }
.ops-sidebar .ops-sub-item .q-item__section--avatar { color: rgba(255,255,255,.7); min-width: 30px; }
.ops-sidebar .ops-sub-item .q-item__label { color: rgba(255,255,255,.82); }
</style>