- InlineField component + useInlineEdit composable for Odoo-style dblclick editing - Client search by name, account ID, and legacy_customer_id (or_filters) - SMS/Email notification panel on ContactCard via n8n webhooks - Ticket reply thread via Communication docs - All migration scripts (51 files) now tracked - Client portal and field tech app added to monorepo - README rewritten with full feature list, migration summary, architecture - CHANGELOG updated with all recent work - ROADMAP updated with current completion status - Removed hardcoded tokens from docs (use $ERP_SERVICE_TOKEN) - .gitignore updated (docker/, .claude/, exports/, .quasar/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
333 lines
12 KiB
Vue
333 lines
12 KiB
Vue
<template>
|
|
<q-page padding>
|
|
<!-- Filters row -->
|
|
<div class="row q-col-gutter-sm q-mb-md items-end">
|
|
<div class="col-12 col-md-3">
|
|
<div class="filter-label">Recherche</div>
|
|
<q-input v-model="search" dense outlined placeholder="Sujet, client, #ticket..." class="ops-search"
|
|
@keyup.enter="loadTickets" clearable @clear="loadTickets">
|
|
<template #prepend><q-icon name="search" /></template>
|
|
</q-input>
|
|
</div>
|
|
<div class="col-6 col-md-2">
|
|
<div class="filter-label">Statut</div>
|
|
<q-select v-model="statusFilter" dense outlined emit-value map-options
|
|
:options="statusOptions" @update:model-value="resetAndLoad" />
|
|
</div>
|
|
<div class="col-6 col-md-2">
|
|
<div class="filter-label">Type / Département</div>
|
|
<q-select v-model="typeFilter" dense outlined emit-value map-options clearable
|
|
:options="issueTypes" @update:model-value="resetAndLoad" placeholder="Tous" />
|
|
</div>
|
|
<div class="col-6 col-md-2">
|
|
<div class="filter-label">Priorité</div>
|
|
<q-select v-model="priorityFilter" dense outlined emit-value map-options clearable
|
|
:options="priorityOptions" @update:model-value="resetAndLoad" placeholder="Toutes" />
|
|
</div>
|
|
<div class="col-6 col-md-2">
|
|
<div class="filter-label">Mes tickets</div>
|
|
<q-btn-toggle v-model="ownerFilter" no-caps dense unelevated
|
|
toggle-color="indigo-6" color="grey-3" text-color="grey-8"
|
|
:options="[
|
|
{ label: 'Tous', value: 'all', icon: 'groups' },
|
|
{ label: 'Mes tickets', value: 'mine', icon: 'person' },
|
|
]"
|
|
@update:model-value="resetAndLoad"
|
|
/>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="text-caption text-grey-6 q-mt-sm">{{ total.toLocaleString() }} tickets</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<q-table
|
|
:rows="tickets" :columns="columns" row-key="name"
|
|
flat bordered class="ops-table clickable-table"
|
|
:loading="loading"
|
|
v-model:pagination="pagination"
|
|
@request="onRequest"
|
|
@row-click="(_, row) => openTicketModal(row)"
|
|
>
|
|
<template #body-cell-important="props">
|
|
<q-td :props="props" style="padding:0 4px">
|
|
<q-icon v-if="props.row.is_important" name="star" color="amber-7" size="18px">
|
|
<q-tooltip>Ticket important</q-tooltip>
|
|
</q-icon>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-legacy_id="props">
|
|
<q-td :props="props">
|
|
<span v-if="props.row.legacy_ticket_id" class="text-caption text-weight-medium text-indigo-6">#{{ props.row.legacy_ticket_id }}</span>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-subject="props">
|
|
<q-td :props="props">
|
|
<div class="text-weight-medium">{{ props.row.subject }}</div>
|
|
<div class="text-caption text-grey-6">{{ props.row.name }}</div>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-customer_name="props">
|
|
<q-td :props="props">
|
|
<router-link v-if="props.row.customer" :to="'/clients/' + props.row.customer" class="erp-link" @click.stop>
|
|
{{ props.row.customer_name || props.row.customer }}
|
|
</router-link>
|
|
<span v-else class="text-grey-5">—</span>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-opening_date="props">
|
|
<q-td :props="props">
|
|
{{ formatDate(props.row.opening_date) }}
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-status="props">
|
|
<q-td :props="props">
|
|
<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" :class="statusClass(props.row.status)">{{ props.row.status }}</span>
|
|
</template>
|
|
</InlineField>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-priority="props">
|
|
<q-td :props="props">
|
|
<InlineField :value="props.row.priority" field="priority" doctype="Issue" :docname="props.row.name"
|
|
type="select" :options="['Low', 'Medium', 'High', 'Urgent']"
|
|
@saved="v => props.row.priority = v.value">
|
|
<template #display>
|
|
<span class="ops-badge" :class="priorityClass(props.row.priority)">{{ props.row.priority }}</span>
|
|
</template>
|
|
</InlineField>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-issue_type="props">
|
|
<q-td :props="props">
|
|
<q-chip v-if="props.row.issue_type" dense size="sm" color="grey-3" text-color="grey-8">
|
|
{{ props.row.issue_type }}
|
|
</q-chip>
|
|
</q-td>
|
|
</template>
|
|
</q-table>
|
|
|
|
<!-- ═══ TICKET DETAIL MODAL ═══ -->
|
|
<DetailModal
|
|
v-model:open="modalOpen"
|
|
:loading="modalLoading"
|
|
doctype="Issue"
|
|
:doc-name="modalTicket?.name"
|
|
:title="modalTicket?.subject"
|
|
:doc="modalDoc"
|
|
:comments="modalComments"
|
|
:comms="modalComms"
|
|
:files="modalFiles"
|
|
@navigate="(dt, name) => loadModalTicket(name)"
|
|
>
|
|
<template #title-prefix>
|
|
<q-icon v-if="modalTicket?.is_important" name="star" color="amber-7" size="18px" class="q-mr-xs" />
|
|
</template>
|
|
<template #title-suffix>
|
|
<template v-if="modalTicket?.legacy_ticket_id"> · <span class="text-indigo-6">#{{ modalTicket.legacy_ticket_id }}</span></template>
|
|
<template v-if="modalTicket?.customer_name"> · {{ modalTicket.customer_name }}</template>
|
|
</template>
|
|
<template #header-actions>
|
|
<q-btn v-if="modalTicket?.customer" flat dense round icon="person" class="q-mr-xs"
|
|
@click="$router.push('/clients/' + modalTicket.customer); modalOpen = false">
|
|
<q-tooltip>Voir le client</q-tooltip>
|
|
</q-btn>
|
|
</template>
|
|
</DetailModal>
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { listDocs, countDocs } from 'src/api/erp'
|
|
import { formatDate } from 'src/composables/useFormatters'
|
|
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
|
|
import { useDetailModal } from 'src/composables/useDetailModal'
|
|
import DetailModal from 'src/components/shared/DetailModal.vue'
|
|
import InlineField from 'src/components/shared/InlineField.vue'
|
|
|
|
const search = ref('')
|
|
const statusFilter = ref('all')
|
|
const typeFilter = ref(null)
|
|
const priorityFilter = ref(null)
|
|
const ownerFilter = ref('all')
|
|
const tickets = ref([])
|
|
const loading = ref(false)
|
|
const total = ref(0)
|
|
const pagination = ref({ page: 1, rowsPerPage: 25, rowsNumber: 0, sortBy: 'creation', descending: true })
|
|
|
|
// Modal state (shared composable)
|
|
const { modalOpen, modalLoading, modalDoc, modalComments, modalComms, modalFiles, openModal } = useDetailModal()
|
|
const modalTicket = ref(null)
|
|
|
|
const statusOptions = [
|
|
{ label: 'Tous', value: 'all' },
|
|
{ label: 'Non fermés', value: 'not_closed' },
|
|
{ label: 'Ouverts', value: 'Open' },
|
|
{ label: 'Répondus', value: 'Replied' },
|
|
{ label: 'Résolus', value: 'Resolved' },
|
|
{ label: 'Fermés', value: 'Closed' },
|
|
]
|
|
|
|
const priorityOptions = [
|
|
{ label: 'Urgent', value: 'Urgent' },
|
|
{ label: 'Haute', value: 'High' },
|
|
{ label: 'Moyenne', value: 'Medium' },
|
|
{ label: 'Basse', value: 'Low' },
|
|
]
|
|
|
|
// Will be populated from ERPNext
|
|
const issueTypes = ref([])
|
|
|
|
const columns = [
|
|
{ name: 'important', label: '', field: 'is_important', align: 'center', style: 'width:30px;padding:0' },
|
|
{ name: 'legacy_id', label: '#', field: 'legacy_ticket_id', align: 'left', sortable: true, style: 'width:70px' },
|
|
{ name: 'subject', label: 'Sujet', field: 'subject', align: 'left' },
|
|
{ name: 'customer_name', label: 'Client', field: 'customer_name', align: 'left', sortable: true },
|
|
{ name: 'issue_type', label: 'Type', field: 'issue_type', align: 'left' },
|
|
{ name: 'opening_date', label: 'Date', field: 'opening_date', align: 'left', sortable: true },
|
|
{ name: 'priority', label: 'Priorité', field: 'priority', align: 'center', sortable: true },
|
|
{ name: 'status', label: 'Statut', field: 'status', align: 'center' },
|
|
]
|
|
|
|
// openExternal used for any link in new tab
|
|
function openExternal (url) {
|
|
window.open(url, '_blank')
|
|
}
|
|
|
|
function buildFilters () {
|
|
const filters = {}
|
|
if (statusFilter.value === 'not_closed') {
|
|
filters.status = ['!=', 'Closed']
|
|
} else if (statusFilter.value !== 'all') {
|
|
filters.status = statusFilter.value
|
|
}
|
|
if (typeFilter.value) filters.issue_type = typeFilter.value
|
|
if (priorityFilter.value) filters.priority = priorityFilter.value
|
|
if (search.value.trim()) {
|
|
const q = search.value.trim()
|
|
// If search is a number, search by legacy ticket ID
|
|
if (/^\d+$/.test(q)) {
|
|
filters.legacy_ticket_id = parseInt(q)
|
|
} else {
|
|
filters.subject = ['like', '%' + q + '%']
|
|
}
|
|
}
|
|
if (ownerFilter.value === 'mine') {
|
|
filters.owner = ['like', '%']
|
|
}
|
|
return filters
|
|
}
|
|
|
|
function resetAndLoad () {
|
|
pagination.value.page = 1
|
|
loadTickets()
|
|
}
|
|
|
|
// Map column names to actual sortable fields
|
|
function getSortField (col) {
|
|
if (col === 'opening_date') return 'creation'
|
|
if (col === 'legacy_id') return 'legacy_ticket_id'
|
|
return col || 'creation'
|
|
}
|
|
|
|
async function loadTickets () {
|
|
loading.value = true
|
|
const filters = buildFilters()
|
|
const limit = Math.min(pagination.value.rowsPerPage, 100)
|
|
|
|
try {
|
|
const [data, count] = await Promise.all([
|
|
listDocs('Issue', {
|
|
filters,
|
|
fields: ['name', 'subject', 'customer_name', 'customer', 'opening_date', 'priority', 'status', 'issue_type', 'owner', 'creation', 'legacy_ticket_id', 'is_important'],
|
|
limit,
|
|
offset: (pagination.value.page - 1) * limit,
|
|
orderBy: 'is_important desc, ' + getSortField(pagination.value.sortBy) + (pagination.value.descending ? ' desc' : ' asc'),
|
|
}),
|
|
countDocs('Issue', filters),
|
|
])
|
|
tickets.value = data
|
|
total.value = count
|
|
pagination.value.rowsNumber = count
|
|
} catch (e) {
|
|
console.error('Failed to load tickets', e)
|
|
tickets.value = []
|
|
total.value = 0
|
|
pagination.value.rowsNumber = 0
|
|
}
|
|
loading.value = false
|
|
}
|
|
|
|
function onRequest (props) {
|
|
pagination.value.page = props.pagination.page
|
|
pagination.value.rowsPerPage = Math.min(props.pagination.rowsPerPage, 100)
|
|
pagination.value.sortBy = props.pagination.sortBy
|
|
pagination.value.descending = props.pagination.descending
|
|
loadTickets()
|
|
}
|
|
|
|
async function openTicketModal (row) {
|
|
modalTicket.value = row
|
|
await openModal('Issue', row.name, row.subject)
|
|
// Sync full doc back to modalTicket for header display
|
|
if (modalDoc.value) modalTicket.value = { ...modalTicket.value, ...modalDoc.value }
|
|
}
|
|
|
|
async function loadModalTicket (ticketName) {
|
|
await openModal('Issue', ticketName)
|
|
if (modalDoc.value) modalTicket.value = { ...modalTicket.value, ...modalDoc.value }
|
|
}
|
|
|
|
async function loadIssueTypes () {
|
|
try {
|
|
const types = await listDocs('Issue Type', {
|
|
fields: ['name'],
|
|
limit: 100,
|
|
orderBy: 'name asc',
|
|
})
|
|
issueTypes.value = types.map(t => ({ label: t.name, value: t.name }))
|
|
} catch {
|
|
issueTypes.value = []
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadIssueTypes()
|
|
await loadTickets()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.filter-label {
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
color: #6b7280;
|
|
margin-bottom: 4px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.025em;
|
|
}
|
|
|
|
.erp-link {
|
|
color: #6366f1;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.erp-link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.clickable-table :deep(tbody tr) {
|
|
cursor: pointer;
|
|
}
|
|
.clickable-table :deep(tbody tr:hover td) {
|
|
background: #eef2ff !important;
|
|
}
|
|
|
|
/* Modal styles are in DetailModal.vue */
|
|
</style>
|