An in-flight TicketsPage change added assigned_staff_email / opened_by_staff_email to ISSUE_FIELDS, but ERPNext v16 refuses those in a `fields` query. The frontend queries ERPNext directly (no bad-field stripping like the hub), so the whole Issue list 400'd → 0 tickets for everyone. Removed the two rejected fields; UserAvatar falls back to the assigned_staff name. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
380 lines
19 KiB
Vue
380 lines
19 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">
|
|
<DynamicFilter v-model="filterState" search-placeholder="Sujet, client, #ticket…"
|
|
:chip-groups="chipGroups" preset-key="filter.tickets" :filtered-count="total" @change="onFilterChange" />
|
|
</div>
|
|
<div class="col-6 col-md-2">
|
|
<div class="filter-label">Portée</div>
|
|
<q-btn-toggle v-model="ownerFilter" no-caps dense unelevated
|
|
toggle-color="primary" color="grey-3" text-color="grey-8"
|
|
:options="[
|
|
{ label: 'Mes tickets', value: 'mine', icon: 'person' },
|
|
{ label: 'Mes départements', value: 'depts', icon: 'hub' },
|
|
{ label: 'Tous', value: 'all', icon: 'groups' },
|
|
]"
|
|
@update:model-value="resetAndLoad"
|
|
/>
|
|
</div>
|
|
<div class="col-auto">
|
|
<FlowQuickButton flat dense icon="account_tree" label="Flows tickets"
|
|
tooltip="Configurer les automatisations pour les tickets"
|
|
category="incident" applies-to="Issue" trigger-event="on_issue_opened" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<DataTable
|
|
:rows="displayedTickets" :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="warning" size="18px">
|
|
<q-tooltip>Ticket important</q-tooltip>
|
|
</q-icon>
|
|
</q-td>
|
|
</template>
|
|
<template #body-cell-legacy_id="props">
|
|
<q-td :props="props">
|
|
<span class="text-caption text-weight-medium text-primary">#{{ props.row.legacy_ticket_id || props.row.name }}</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-sla="props">
|
|
<q-td :props="props">
|
|
<q-badge v-if="slaMap[props.row.name] && slaMap[props.row.name].state !== 'none'"
|
|
:color="(SLA_BADGE[slaMap[props.row.name].state] || {}).color || 'grey-3'"
|
|
:text-color="(SLA_BADGE[slaMap[props.row.name].state] || {}).text || 'grey-7'">
|
|
<q-icon :name="(SLA_BADGE[slaMap[props.row.name].state] || {}).icon || 'schedule'" size="11px" class="q-mr-xs" />{{ slaMap[props.row.name].label }}
|
|
</q-badge>
|
|
<span v-else class="text-grey-4">—</span>
|
|
</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>
|
|
<template #body-cell-assigned="props">
|
|
<q-td :props="props" class="text-center">
|
|
<UserAvatar v-if="props.row.assigned_staff || props.row.assigned_staff_email" :ident="props.row.assigned_staff_email" :name="props.row.assigned_staff" :size="26" card />
|
|
<span v-else class="text-grey-4">—</span>
|
|
</q-td>
|
|
</template>
|
|
<!-- Mode cartes (<1024px) : rangée = composant CANONIQUE TicketCard (le même
|
|
que la fiche client + LocationCard), au lieu de la carte clé/valeur générique. -->
|
|
<template #item="props">
|
|
<div class="col-12 q-pa-xs">
|
|
<TicketCard :ticket="props.row" @open="openTicketModal(props.row)" />
|
|
</div>
|
|
</template>
|
|
</DataTable>
|
|
|
|
<DetailModal
|
|
v-model:open="modalOpen"
|
|
:loading="modalLoading"
|
|
doctype="Issue"
|
|
:doc-name="modalTicket?.name"
|
|
:title="modalTicket?.subject"
|
|
:doc="modalDoc"
|
|
:comments="modalComments"
|
|
:comms="modalComms"
|
|
:files="modalFiles"
|
|
:dispatch-jobs="modalDispatchJobs"
|
|
@navigate="(dt, name) => { if (dt === 'Customer') { $router.push('/clients/' + name); modalOpen = false } else { loadModalTicket(name) } }"
|
|
@dispatch-created="j => modalDispatchJobs.push(j)"
|
|
@dispatch-deleted="name => { modalDispatchJobs = modalDispatchJobs.filter(j => j.name !== name) }"
|
|
@deleted="onTicketDeleted"
|
|
>
|
|
<template #title-prefix>
|
|
<q-icon v-if="modalTicket?.is_important" name="star" color="warning" size="18px" class="q-mr-xs" />
|
|
</template>
|
|
<template #title-suffix>
|
|
<span> · <span class="text-primary">#{{ modalTicket?.legacy_ticket_id || modalTicket?.name }}</span></span>
|
|
<template v-if="modalTicket?.customer_name"> · <router-link v-if="modalTicket?.customer" :to="'/clients/' + encodeURIComponent(modalTicket.customer)" class="erp-link">{{ modalTicket.customer_name }}</router-link><template v-else>{{ modalTicket.customer_name }}</template></template>
|
|
</template>
|
|
<template #header-actions>
|
|
<q-btn flat dense no-caps :icon="myFollows.includes(modalTicket?.name) ? 'star' : 'star_border'"
|
|
:color="myFollows.includes(modalTicket?.name) ? 'amber-7' : 'grey-6'"
|
|
:label="myFollows.includes(modalTicket?.name) ? 'Suivi' : 'Suivre'" class="q-mr-xs"
|
|
@click="toggleFollow(modalTicket?.name)">
|
|
<q-tooltip>Suivre ce ticket — il apparaît dans « Mes tickets »</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat dense no-caps icon="add_task" color="positive" label="Tâche" class="q-mr-xs"
|
|
@click="addTaskOpen = true">
|
|
<q-tooltip>Ajouter ce ticket à une tâche (existante ou nouvelle)</q-tooltip>
|
|
</q-btn>
|
|
<q-btn flat dense no-caps icon="forum" color="primary" label="Intervenir" class="q-mr-xs"
|
|
@click="openIntervene(modalTicket)">
|
|
<q-tooltip>Note / mention sans changer l'assignation</q-tooltip>
|
|
</q-btn>
|
|
<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>
|
|
|
|
<InterveneDialog v-model="intervene.open" :name="intervene.name" :title="intervene.title" doctype="Issue" @followed="onIntervenedFollow" />
|
|
<AddToTaskDialog v-model="addTaskOpen" :source="taskSource" />
|
|
</q-page>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { listDocs, countDocs } from 'src/api/erp'
|
|
import DataTable from 'src/components/shared/DataTable.vue'
|
|
import { formatDate, staffColor, staffInitials } from 'src/composables/useFormatters'
|
|
import { ticketStatusClass as statusClass, priorityClass } from 'src/composables/useStatusClasses'
|
|
import { useDetailModal } from 'src/composables/useDetailModal'
|
|
import { statusOptions, priorityOptions, columns, buildFilters, getSortField } from 'src/config/ticket-config'
|
|
import DynamicFilter from 'src/components/shared/DynamicFilter.vue'
|
|
import { useSla, SLA_BADGE } from 'src/composables/useSla'
|
|
import { useAuthStore } from 'src/stores/auth'
|
|
import { HUB_URL } from 'src/config/hub' // pour résoudre « mes files » (queue-members) → issue_types de mes départements
|
|
import InterveneDialog from 'src/components/shared/InterveneDialog.vue'
|
|
import AddToTaskDialog from 'src/components/shared/AddToTaskDialog.vue'
|
|
|
|
const intervene = ref({ open: false, name: '', title: '' })
|
|
const addTaskOpen = ref(false)
|
|
const taskSource = computed(() => ({ type: 'Issue', name: modalTicket.value?.name || '', title: modalTicket.value?.subject || '', customer: modalTicket.value?.customer || '' }))
|
|
function openIntervene (t) { if (t) intervene.value = { open: true, name: t.name, title: t.subject || t.name } }
|
|
import DetailModal from 'src/components/shared/DetailModal.vue'
|
|
import InlineField from 'src/components/shared/InlineField.vue'
|
|
import FlowQuickButton from 'src/components/flow-editor/FlowQuickButton.vue'
|
|
import TicketCard from 'src/components/customer/TicketCard.vue'
|
|
import UserAvatar from 'src/components/shared/UserAvatar.vue'
|
|
|
|
const me = useAuthStore().user // courriel de l'agent connecté (pour « Mes tickets »)
|
|
const { slaFor } = useSla() // calcul SLA (politiques par file+priorité éditées dans Paramètres)
|
|
const search = ref('')
|
|
const statusFilter = ref('not_closed') // défaut = NON FERMÉS → évite les vieux tickets fermés (clients inactifs depuis des années)
|
|
const typeFilter = ref(null)
|
|
const priorityFilter = ref(null)
|
|
const ownerFilter = ref('mine') // défaut « façon F » : on atterrit sur MES tickets assignés (priorisés), bascule « Tous » dispo
|
|
// DynamicFilter (recherche + chips statut/type/priorité + presets) → mappe vers les refs existantes qui alimentent buildFilters.
|
|
const filterState = ref({ search: '', chips: { statut: statusFilter.value } }) // statut défaut = not_closed
|
|
const chipGroups = computed(() => [
|
|
{ key: 'statut', label: 'Statut', icon: 'label', options: statusOptions.filter(o => o.value !== 'all') },
|
|
{ key: 'type', label: 'Type', icon: 'category', options: issueTypes.value },
|
|
{ key: 'priorite', label: 'Priorité', icon: 'priority_high', options: priorityOptions },
|
|
])
|
|
let filterTimer = null
|
|
function onFilterChange (s) {
|
|
search.value = s.search || ''
|
|
statusFilter.value = s.chips.statut || 'all'
|
|
typeFilter.value = s.chips.type || null
|
|
priorityFilter.value = s.chips.priorite || null
|
|
clearTimeout(filterTimer)
|
|
filterTimer = setTimeout(() => resetAndLoad(), 300)
|
|
}
|
|
const tickets = ref([])
|
|
// Tri « façon F » : dans les vues scopées (Mes tickets), RANGER par priorité (Urgent→Bas) côté client —
|
|
// ERPNext+Postgres ne range pas une priorité Select dans l'ORDER BY, et l'ensemble scopé tient sur 1 page.
|
|
const PRIO_RANK = { Urgent: 4, High: 3, Medium: 2, Low: 1 }
|
|
const displayedTickets = computed(() => {
|
|
if (ownerFilter.value === 'all') return tickets.value // vue globale = ordre serveur (date)
|
|
let rows = tickets.value
|
|
// mode « tags » sur « Mes départements » : ne garder que les tickets portant un de mes sous-tags (réduction du bruit)
|
|
if (ownerFilter.value === 'depts' && myMode.value === 'tags' && myTags.value.length) {
|
|
rows = rows.filter(t => { const tg = String(t._user_tags || '').toLowerCase(); return myTags.value.some(x => tg.includes(x)) })
|
|
}
|
|
return [...rows].sort((a, b) =>
|
|
((b.is_important ? 1 : 0) - (a.is_important ? 1 : 0)) ||
|
|
((PRIO_RANK[b.priority] || 0) - (PRIO_RANK[a.priority] || 0)) ||
|
|
String(b.opening_date || '').localeCompare(String(a.opening_date || '')))
|
|
})
|
|
// Carte name→SLA (réactive aux politiques chargées + aux tickets) : 1 calcul/ticket, pas de mutation des lignes source.
|
|
const slaMap = computed(() => { const m = {}; for (const t of (tickets.value || [])) m[t.name] = slaFor(t); return m })
|
|
const loading = ref(false)
|
|
const total = ref(0)
|
|
const pagination = ref({ page: 1, rowsPerPage: 100, rowsNumber: 0, sortBy: 'opening_date', descending: true }) // défaut = vue scopée « Mes tickets » sur 1 page (tri priorité client) ; vue « Tous » repasse à 25 (resetAndLoad)
|
|
|
|
const { modalOpen, modalLoading, modalDoc, modalComments, modalComms, modalFiles, modalDispatchJobs, openModal } = useDetailModal()
|
|
const modalTicket = ref(null)
|
|
|
|
const issueTypes = ref([])
|
|
const myIssueTypes = ref([]) // issue_types des FILES dont l'agent est membre (pour le scope « Mes départements »)
|
|
const myMode = ref('department') // mode de visibilité de l'agent : personnel | department | tags (config Paramètres)
|
|
const myTags = ref([]) // sous-tags de l'agent (mode 'tags')
|
|
const myFollows = ref([]) // tickets suivis (watch perso) → toujours dans « Mes tickets »
|
|
const followHdr = () => (me && me !== 'authenticated' ? { 'Content-Type': 'application/json', 'X-Authentik-Email': me } : { 'Content-Type': 'application/json' })
|
|
|
|
function resetAndLoad () {
|
|
pagination.value.page = 1
|
|
pagination.value.rowsPerPage = ownerFilter.value === 'all' ? 25 : 100 // vue scopée = 1 page (tri priorité client global) ; globale = pagination serveur 25
|
|
loadTickets()
|
|
}
|
|
|
|
// ⚠️ ERPNext v16 REFUSE `assigned_staff_email` / `opened_by_staff_email` dans une requête `fields` → la liste Issue
|
|
// plantait ENTIÈREMENT (0 ticket) côté frontend (listDocs interroge ERPNext direct, sans le strip du hub). Retirés
|
|
// jusqu'à ce que ces champs custom soient interrogeables ; UserAvatar retombe sur le nom (`assigned_staff`).
|
|
const ISSUE_FIELDS = ['name', 'subject', 'customer_name', 'customer', 'opening_date', 'priority', 'status', 'issue_type', 'assigned_staff', 'opened_by_staff', 'owner', 'creation', 'legacy_ticket_id', 'is_important', 'first_responded_on', '_user_tags']
|
|
async function loadTickets () {
|
|
loading.value = true
|
|
const filters = buildFilters({
|
|
statusFilter: statusFilter.value,
|
|
typeFilter: typeFilter.value,
|
|
priorityFilter: priorityFilter.value,
|
|
search: search.value,
|
|
ownerFilter: ownerFilter.value,
|
|
me,
|
|
deptIssueTypes: myIssueTypes.value,
|
|
})
|
|
const limit = Math.min(pagination.value.rowsPerPage, 100)
|
|
try {
|
|
const [data, count] = await Promise.all([
|
|
listDocs('Issue', {
|
|
filters,
|
|
fields: ISSUE_FIELDS,
|
|
limit,
|
|
offset: (pagination.value.page - 1) * limit,
|
|
orderBy: 'is_important desc, ' + getSortField(pagination.value.sortBy) + (pagination.value.descending ? ' desc' : ' asc') + ', creation desc', // tiebreaker stable (opening_date = DATE sans heure)
|
|
}),
|
|
countDocs('Issue', filters),
|
|
])
|
|
tickets.value = data
|
|
total.value = count
|
|
pagination.value.rowsNumber = count
|
|
// « Mes tickets » inclut aussi les tickets SUIVIS (watch perso) non déjà assignés
|
|
if (ownerFilter.value === 'mine' && myFollows.value.length) {
|
|
const missing = myFollows.value.filter(n => !data.some(t => t.name === n))
|
|
if (missing.length) {
|
|
const extra = await listDocs('Issue', { filters: { name: ['in', missing] }, fields: ISSUE_FIELDS, limit: 100 }).catch(() => [])
|
|
if (extra.length) tickets.value = [...data, ...extra]
|
|
}
|
|
}
|
|
} catch {
|
|
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)
|
|
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 }
|
|
}
|
|
|
|
function onTicketDeleted (name) {
|
|
modalOpen.value = false
|
|
tickets.value = tickets.value.filter(t => t.name !== name)
|
|
total.value = Math.max(0, total.value - 1)
|
|
pagination.value.rowsNumber = total.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 = []
|
|
}
|
|
}
|
|
|
|
// « Mes départements » = les issue_types des FILES comms dont je suis membre (queue-members + map issueTypeQueue, source unique categories.js).
|
|
async function loadMyDepartments () {
|
|
try {
|
|
const r = await fetch(`${HUB_URL}/conversations/queue-members`).then(x => x.json())
|
|
const meLc = String(me || '').toLowerCase()
|
|
const myQueues = (r.queues || []).filter(q => (r.members?.[q] || []).map(e => String(e).toLowerCase()).includes(meLc))
|
|
const map = r.issueTypeQueue || {}
|
|
myIssueTypes.value = Object.keys(map).filter(it => myQueues.includes(map[it]))
|
|
const vis = (r.visibility || {})[meLc] || {}
|
|
myMode.value = vis.mode || 'department'
|
|
myTags.value = Array.isArray(vis.tags) ? vis.tags.map(t => String(t).toLowerCase()) : []
|
|
} catch { myIssueTypes.value = [] }
|
|
}
|
|
async function loadMyFollows () {
|
|
try { const r = await fetch(`${HUB_URL}/conversations/ticket-follow`, { headers: followHdr() }).then(x => x.json()); myFollows.value = r.follows || [] } catch { myFollows.value = [] }
|
|
}
|
|
async function toggleFollow (name) {
|
|
if (!name) return
|
|
const following = myFollows.value.includes(name)
|
|
try {
|
|
await fetch(`${HUB_URL}/conversations/ticket-follow`, { method: 'POST', headers: followHdr(), body: JSON.stringify({ ticket: name, follow: !following }) })
|
|
myFollows.value = following ? myFollows.value.filter(n => n !== name) : [...myFollows.value, name]
|
|
} catch (e) { /* suivi best-effort */ }
|
|
}
|
|
// Le dialogue « Intervenir » peut aussi (dé)suivre → garder l'étoile de l'en-tête synchronisée.
|
|
function onIntervenedFollow ({ name, doctype, following }) {
|
|
if (doctype !== 'Issue' || !name) return
|
|
myFollows.value = following ? [...new Set([...myFollows.value, name])] : myFollows.value.filter(n => n !== name)
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([loadIssueTypes(), loadMyDepartments(), loadMyFollows()])
|
|
ownerFilter.value = myMode.value === 'personnel' ? 'mine' : 'depts' // scope par défaut selon le mode de la personne (department/tags → « Mes départements »)
|
|
pagination.value.rowsPerPage = ownerFilter.value === 'all' ? 25 : 100
|
|
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; }
|
|
</style>
|