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:
parent
4693bcf60c
commit
a2c59d6528
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<span class="ops-badge" style="font-size:10px;padding:1px 6px" :class="ticketStatusClass(props.row.status)">{{ props.row.status }}</span>
|
<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>
|
||||||
|
</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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user