feat: ticket lazy-load, inline editing, search improvements

- Tickets: load 10 initially, "Voir tous les tickets" expands to 500
- Inline editing for ticket status and priority (dblclick → select)
- Search: Enter key triggers immediate search and navigates to result
- Search: Arrow key navigation for result highlighting
- Reset expanded state on customer navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-04-02 14:43:25 -04:00
parent 4693bcf60c
commit a2c59d6528
2 changed files with 62 additions and 13 deletions

View File

@ -45,7 +45,7 @@
</q-toolbar> </q-toolbar>
<div v-if="mobileSearchOpen && !isDispatch" class="q-px-sm q-pb-sm" style="background:var(--ops-sidebar-bg)"> <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" <q-input v-model="searchQuery" placeholder="Rechercher client, adresse..." dense outlined dark autofocus class="ops-search-dark"
@keyup.enter="doSearch" @keydown.escape="closeMobileSearch"> @keydown.enter.prevent="doSearch" @keydown.escape="closeMobileSearch">
<template #prepend><q-icon name="search" color="grey-5" /></template> <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> <template #append v-if="searchQuery"><q-icon name="close" class="cursor-pointer" color="grey-5" @click="clearSearch" /></template>
</q-input> </q-input>
@ -69,7 +69,8 @@
<q-space /> <q-space />
<div style="position:relative;width:400px"> <div style="position:relative;width:400px">
<q-input v-model="searchQuery" placeholder="Rechercher client, adresse, ticket..." dense outlined class="ops-search" <q-input v-model="searchQuery" placeholder="Rechercher client, adresse, ticket..." dense outlined class="ops-search"
@keyup.enter="doSearch" @keydown.escape="clearSearch" @keydown.enter.prevent="doSearch" @keydown.escape="clearSearch"
@keydown.down.prevent="moveHighlight(1)" @keydown.up.prevent="moveHighlight(-1)"
@update:model-value="onSearchInput" @blur="onSearchBlur"> @update:model-value="onSearchInput" @blur="onSearchBlur">
<template #prepend><q-icon name="search" color="grey-6" /></template> <template #prepend><q-icon name="search" color="grey-6" /></template>
<template #append v-if="searchQuery"> <template #append v-if="searchQuery">
@ -189,12 +190,28 @@ async function runSearch (q) {
searchLoading.value = false searchLoading.value = false
} }
function doSearch () { 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) { if (searchResults.value.length) {
const idx = highlightIdx.value >= 0 ? highlightIdx.value : 0 const idx = highlightIdx.value >= 0 ? highlightIdx.value : 0
goToResult(searchResults.value[idx]) goToResult(searchResults.value[idx])
} else if (searchQuery.value.trim()) { } else {
router.push({ path: '/clients', query: { q: searchQuery.value.trim() } }) router.push({ path: '/clients', query: { q } })
clearSearch() clearSearch()
} }
} }

View File

@ -264,7 +264,7 @@
<template #header> <template #header>
<div class="section-title" style="font-size:1rem;width:100%"> <div class="section-title" style="font-size:1rem;width:100%">
<q-icon name="confirmation_number" size="20px" class="q-mr-xs" /> <q-icon name="confirmation_number" size="20px" class="q-mr-xs" />
Tickets ({{ tickets.length }}) Tickets ({{ tickets.length }}{{ !ticketsExpanded ? '+' : '' }})
<span v-if="openTicketCount" class="ops-badge open q-ml-sm">{{ openTicketCount }} ouverts</span> <span v-if="openTicketCount" class="ops-badge open q-ml-sm">{{ openTicketCount }} ouverts</span>
</div> </div>
</template> </template>
@ -316,17 +316,31 @@
</template> </template>
<template #body-cell-priority="props"> <template #body-cell-priority="props">
<q-td :props="props" style="padding:0 2px"> <q-td :props="props" style="padding:0 2px">
<q-icon v-if="props.row.priority === 'Urgent'" name="priority_high" color="red" size="16px" :title="'Urgent'" /> <InlineField :value="props.row.priority" field="priority" doctype="Issue" :docname="props.row.name"
<q-icon v-else-if="props.row.priority === 'High'" name="arrow_upward" color="orange-8" size="14px" :title="'High'" /> type="select" :options="['Low', 'Medium', 'High', 'Urgent']"
<q-icon v-else-if="props.row.priority === 'Low'" name="arrow_downward" color="blue-grey-4" size="14px" :title="'Low'" /> @saved="v => props.row.priority = v.value">
<template #display>
<span class="ops-badge" style="font-size:10px;padding:1px 6px" :class="priorityClass(props.row.priority)">{{ props.row.priority }}</span>
</template>
</InlineField>
</q-td> </q-td>
</template> </template>
<template #body-cell-status="props"> <template #body-cell-status="props">
<q-td :props="props" style="padding:0 4px"> <q-td :props="props" style="padding:0 4px">
<InlineField :value="props.row.status" field="status" doctype="Issue" :docname="props.row.name"
type="select" :options="['Open', 'Replied', 'On Hold', 'Resolved', 'Closed']"
@saved="v => props.row.status = v.value">
<template #display>
<span class="ops-badge" style="font-size:10px;padding:1px 6px" :class="ticketStatusClass(props.row.status)">{{ props.row.status }}</span> <span class="ops-badge" style="font-size:10px;padding:1px 6px" :class="ticketStatusClass(props.row.status)">{{ props.row.status }}</span>
</template>
</InlineField>
</q-td> </q-td>
</template> </template>
</q-table> </q-table>
<div v-if="!ticketsExpanded && tickets.length >= 10" class="text-center q-pa-xs">
<q-btn flat dense no-caps color="indigo-6" :loading="loadingMoreTickets"
label="Voir tous les tickets" icon="expand_more" @click="loadAllTickets" />
</div>
</div> </div>
</q-expansion-item> </q-expansion-item>
@ -470,7 +484,7 @@ import { listDocs, getDoc } from 'src/api/erp'
import { authFetch } from 'src/api/auth' import { authFetch } from 'src/api/auth'
import { BASE_URL } from 'src/config/erpnext' import { BASE_URL } from 'src/config/erpnext'
import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters' import { formatDate, formatMoney, staffColor, staffInitials } from 'src/composables/useFormatters'
import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass } from 'src/composables/useStatusClasses' import { locStatusClass, ticketStatusClass, invStatusClass, deviceColorClass, priorityClass } from 'src/composables/useStatusClasses'
import { useDetailModal } from 'src/composables/useDetailModal' import { useDetailModal } from 'src/composables/useDetailModal'
import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups' import { useSubscriptionGroups, isRebate, subMainLabel, sectionTotal, annualPrice } from 'src/composables/useSubscriptionGroups'
import { useSubscriptionActions } from 'src/composables/useSubscriptionActions' import { useSubscriptionActions } from 'src/composables/useSubscriptionActions'
@ -591,6 +605,7 @@ async function loadCustomer (id) {
comments.value = [] comments.value = []
contact.value = null contact.value = null
modalOpen.value = false modalOpen.value = false
ticketsExpanded.value = false
invoicesExpanded.value = false invoicesExpanded.value = false
paymentsExpanded.value = false paymentsExpanded.value = false
@ -635,7 +650,7 @@ async function loadCustomer (id) {
listDocs('Issue', { listDocs('Issue', {
filters: custFilter, filters: custFilter,
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'], fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
limit: 200, orderBy: 'opening_date desc', limit: 10, orderBy: 'is_important desc, opening_date desc',
}), }),
listDocs('Sales Invoice', { listDocs('Sales Invoice', {
filters: custFilter, filters: custFilter,
@ -677,12 +692,29 @@ async function loadCustomer (id) {
} }
} }
// Lazy-load more invoices/payments on demand // Lazy-load more tickets/invoices/payments on demand
const ticketsExpanded = ref(false)
const invoicesExpanded = ref(false) const invoicesExpanded = ref(false)
const paymentsExpanded = ref(false) const paymentsExpanded = ref(false)
const loadingMoreTickets = ref(false)
const loadingMoreInvoices = ref(false) const loadingMoreInvoices = ref(false)
const loadingMorePayments = ref(false) const loadingMorePayments = ref(false)
async function loadAllTickets () {
if (ticketsExpanded.value || !customer.value) return
loadingMoreTickets.value = true
try {
const tix = await listDocs('Issue', {
filters: { customer: customer.value.name },
fields: ['name', 'subject', 'status', 'priority', 'opening_date', 'service_location', 'legacy_ticket_id', 'is_important', 'assigned_staff', 'opened_by_staff', 'issue_type'],
limit: 500, orderBy: 'is_important desc, opening_date desc',
})
tickets.value = tix
ticketsExpanded.value = true
} catch {}
loadingMoreTickets.value = false
}
async function loadAllInvoices () { async function loadAllInvoices () {
if (invoicesExpanded.value || !customer.value) return if (invoicesExpanded.value || !customer.value) return
loadingMoreInvoices.value = true loadingMoreInvoices.value = true