gigafibre-fsm/apps/ops/src/layouts/MainLayout.vue
louispaulb 4a641ac802 perf(ops): lazy-load heavy libs & shell components — chunks route/shell bien plus légers
Batch 1 de l'audit d'optimisation. Les libs lourdes étaient statiquement importées
donc livrées même inutilisées → passées en import dynamique / defineAsyncComponent :
- MainLayout : ConversationPanel/PhoneModal/FlowEditor/NewTicket/Orchestrator/
  ServiceStatus/OutboxPanel → async (chunk shell 312 KB → 56 KB, chargé sur CHAQUE page)
- TaskGraphPage : TaskGantt (hy-vue-gantt) → async (2.6 MB → 20 KB ; le Gantt ne charge
  que dans la vue Gantt)
- NetworkPage : cytoscape → import() dans loadNetworkMap (484 KB → 52 KB)
- ReportAR/Revenu/Taxes : chart.js/auto → import() dans renderChart (async) (~150→~10 KB/page)

Total dist inchangé (~8 M) — le code est DÉFÉRÉ en chunks à la demande, pas supprimé.
Gain réel = ce que l'utilisateur télécharge pour VOIR une page. Build OK, leak-check propre,
shell + route rapport vérifiés (aucune erreur d'import).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 20:35:30 -04:00

369 lines
20 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="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>{{ userName || auth.user || 'User' }}</q-item-label></q-item-section>
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ userName || auth.user || '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-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 v-if="searchQuery">
<q-icon name="close" class="cursor-pointer" @click="clearSearch" />
</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" />
</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="column items-end q-gutter-sm">
<q-slide-transition>
<div v-show="quickOpen" class="column items-end q-gutter-xs">
<q-btn rounded unelevated color="deep-purple-6" icon="bolt" label="Dicter une commande" no-caps size="sm" @click="quickOpen = false; orchestratorOpen = true" />
<q-btn rounded unelevated color="info" icon="wifi_find" label="Statut du service" no-caps size="sm" @click="quickOpen = false; serviceStatusOpen = true" />
<q-btn rounded unelevated color="positive" icon="confirmation_number" label="Créer un ticket" no-caps size="sm" @click="quickOpen = false; newTicketOpen = true" />
<q-btn rounded unelevated color="green-6" icon="sms" label="Nouveau texto" no-caps size="sm" @click="quickOpen = false; openNew('sms')" />
<q-btn rounded unelevated color="negative" icon="mail" label="Nouveau courriel" no-caps size="sm" @click="quickOpen = false; openNew('email')" />
</div>
</q-slide-transition>
<div class="row items-center q-gutter-xs">
<q-btn round unelevated :color="quickOpen ? 'blue-grey-7' : 'blue-grey-4'" :icon="quickOpen ? 'close' : 'add'" size="sm" @click="quickOpen = !quickOpen">
<q-tooltip>Créer rapidement (ticket · texto · courriel · dicter)</q-tooltip>
</q-btn>
<q-btn fab icon="forum" color="primary" @click="toggleConvPanel">
<q-badge v-if="convCount > 0" color="red" floating rounded :label="convCount" />
<q-tooltip>Voir les communications</q-tooltip>
</q-btn>
</div>
</div>
</q-page-sticky>
<!-- Création rapide d'un ticket depuis le FAB -->
<NewTicketDialog v-if="can('view_clients')" v-model="newTicketOpen" />
<!-- Commande dictée / langage naturel → orchestre plusieurs actions -->
<OrchestratorDialog v-if="can('view_clients')" v-model="orchestratorOpen" />
<!-- 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')" />
</q-layout>
</template>
<script setup>
import { ref, computed, nextTick, watch, defineAsyncComponent } 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,
} 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 { useConversations } from 'src/composables/useConversations'
const FlowEditorDialog = defineAsyncComponent(() => import('src/components/flow-editor/FlowEditorDialog.vue'))
const NewTicketDialog = defineAsyncComponent(() => import('src/components/shared/NewTicketDialog.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 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 }
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
const { open: spOpen, number: spNumber, customer: spCustomer, customerName: spCustomerName, provider: spProvider, handleCallEnded: spHandleCallEnded } = useSoftphone()
function toggleConvPanel () { panelOpen.value = !panelOpen.value }
// FAB messagerie : clic « forum » = ouvrir les communications ; bouton « + » (clic, pas survol) = menu créer rapide.
const quickOpen = ref(false)
const newTicketOpen = ref(false)
const orchestratorOpen = ref(false)
const serviceStatusOpen = ref(false)
function openNew (channel) { newDialogChannel.value = channel; newDialogOpen.value = true }
const auth = useAuthStore()
const { can, isLoaded, userName } = usePermissions()
// 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>
/* 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>