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>
414 lines
24 KiB
Vue
414 lines
24 KiB
Vue
<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>
|