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>
310 lines
16 KiB
Vue
310 lines
16 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>
|
|
|
|
<template v-for="(nav, i) in navItems" :key="nav.path">
|
|
<q-item-label v-if="!collapsed && nav.group === 'admin' && (i === 0 || navItems[i - 1].group !== 'admin')" header
|
|
style="color:rgba(255,255,255,.45);font-size:.68rem;letter-spacing:.05em;text-transform:uppercase;padding:14px 16px 4px">Administration</q-item-label>
|
|
<q-item clickable :to="nav.path"
|
|
:class="{ 'active-link': isActive(nav.path), 'justify-center': collapsed }"
|
|
:title="collapsed ? nav.label : undefined">
|
|
<q-item-section avatar><component :is="icons[nav.icon]" :size="20" /></q-item-section>
|
|
<q-item-section v-if="!collapsed"><q-item-label>{{ nav.label }}</q-item-label></q-item-section>
|
|
<q-item-section side v-if="nav.badge && !collapsed"><q-badge color="red" :label="nav.badge" rounded /></q-item-section>
|
|
<q-tooltip v-if="collapsed" anchor="center right" self="center left" :offset="[8, 0]">{{ nav.label }}</q-tooltip>
|
|
</q-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 />
|
|
<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' ? 'indigo-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' ? 'indigo-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>
|
|
</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/')" />
|
|
|
|
<!-- 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="cyan-8" icon="wifi_find" label="Statut du service" no-caps size="sm" @click="quickOpen = false; serviceStatusOpen = true" />
|
|
<q-btn rounded unelevated color="teal-7" 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="red-5" 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="indigo-6" @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 } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useAuthStore } from 'src/stores/auth'
|
|
import { usePermissions } from 'src/composables/usePermissions'
|
|
import { listDocs } from 'src/api/erp'
|
|
import { navItems as allNavItems } 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,
|
|
} from 'lucide-vue-next'
|
|
import ConversationPanel from 'src/components/shared/ConversationPanel.vue'
|
|
import { useConversations } from 'src/composables/useConversations'
|
|
import FlowEditorDialog from 'src/components/flow-editor/FlowEditorDialog.vue'
|
|
import NewTicketDialog from 'src/components/shared/NewTicketDialog.vue'
|
|
import OrchestratorDialog from 'src/components/shared/OrchestratorDialog.vue'
|
|
import ServiceStatusDialog from 'src/components/shared/ServiceStatusDialog.vue'
|
|
import OutboxPanel from '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 }
|
|
|
|
const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount } = useConversations()
|
|
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 drawer = ref(true)
|
|
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 {
|
|
const cf = ['name', 'customer_name', 'customer_type', 'territory', 'disabled']
|
|
const lf = ['name', 'address_line', 'city', 'customer', 'customer_name', 'status']
|
|
const timeout = new Promise((_, r) => setTimeout(() => r(new Error('timeout')), 4000))
|
|
const [cName, cId, lAddr, lCity] = await Promise.race([
|
|
Promise.all([
|
|
listDocs('Customer', { filters: { customer_name: ['like', '%' + q + '%'] }, fields: cf, limit: 6, orderBy: 'customer_name asc' }).catch(() => []),
|
|
listDocs('Customer', { filters: { name: ['like', '%' + q + '%'] }, fields: cf, limit: 4, orderBy: 'name asc' }).catch(() => []),
|
|
listDocs('Service Location', { filters: { address_line: ['like', '%' + q + '%'] }, fields: lf, limit: 6, orderBy: 'address_line asc' }).catch(() => []),
|
|
listDocs('Service Location', { filters: { city: ['like', '%' + q + '%'] }, fields: lf, limit: 4, orderBy: 'city asc' }).catch(() => []),
|
|
]),
|
|
timeout,
|
|
])
|
|
const seen = new Set()
|
|
const out = []
|
|
for (const c of [...cName, ...cId]) {
|
|
if (seen.has(c.name)) continue; seen.add(c.name)
|
|
out.push({ id: 'c-' + c.name, type: 'customer', typeLabel: 'Client', icon: 'person', title: c.customer_name, sub: c.name + (c.territory ? ' · ' + c.territory : ''), route: '/clients/' + c.name })
|
|
}
|
|
for (const l of [...lAddr, ...lCity]) {
|
|
if (seen.has(l.name)) continue; seen.add(l.name)
|
|
out.push({ id: 'l-' + l.name, type: 'location', typeLabel: 'Adresse', icon: 'location_on', title: l.address_line + (l.city ? ', ' + l.city : ''), sub: (l.customer_name || l.customer) + ' · ' + l.status, route: '/clients/' + l.customer })
|
|
}
|
|
searchResults.value = out.slice(0, 12)
|
|
} 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()
|
|
}
|
|
}
|
|
|
|
function goToResult (r) {
|
|
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>
|