Backend: - Add MCP Serializer to centralize entity-to-array conversion (~300 lines deduped) - Fix race condition in task/ticket number generation (SELECT FOR UPDATE + transaction) - Add unique constraint on task (project_id, number) with migration - Fix MIME type validation: use server-detected finfo instead of client-supplied type - Add allowlist of permitted MIME types for uploads - Fix TaskDocumentDownloadController: allow ROLE_CLIENT access, add priority:1 - Fix notification sent even when ticket status unchanged - Remove redundant exception constructors - Simplify services (BookStackApi double fetch, TokenEncryptor, GiteaApi) - Consolidate duplicate checks in processors Frontend: - Fix useApi isHandlingUnauthorized scope (module-level to prevent double 401 redirect) - Fix client-tickets toast key copy-paste bug - Merge duplicated tasks service methods (getByProject + getByProjectArchived) - Extract shared uploadWithRelation helper in task-documents service - Extract formatFileSize utility from duplicated component code - Extract status transition logic into useClientTicketHelpers composable - Remove dead code (unused router, handleLogout, empty script blocks) - Merge duplicate watchers and onMounted calls - Normalize arrow functions to function declarations per convention Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
328 lines
15 KiB
Vue
328 lines
15 KiB
Vue
<template>
|
|
<div>
|
|
<!-- Trigger button -->
|
|
<button
|
|
class="relative flex shrink-0 items-center gap-2 rounded-md bg-neutral-100 px-3 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-200 sm:px-4"
|
|
@click="open"
|
|
>
|
|
<Icon name="mdi:ticket-outline" class="size-4 sm:size-5" />
|
|
<span class="hidden sm:inline">{{ $t('clientTicket.adminTab') }}</span>
|
|
<span
|
|
v-if="totalCount > 0"
|
|
class="flex h-5 min-w-5 items-center justify-center rounded-full bg-primary-500 px-1 text-xs font-bold text-white"
|
|
>
|
|
{{ totalCount }}
|
|
</span>
|
|
</button>
|
|
|
|
<!-- Panel -->
|
|
<Teleport v-if="isOpen" to="body">
|
|
<Transition name="ct-panel" appear>
|
|
<div class="fixed inset-0 z-50 flex justify-end">
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="close"
|
|
/>
|
|
|
|
<!-- Slide panel -->
|
|
<div class="relative z-10 flex h-full w-full max-w-lg flex-col bg-white shadow-2xl">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between border-b border-neutral-200 px-5 py-4">
|
|
<div>
|
|
<h2 class="text-base font-bold text-neutral-900">{{ $t('clientTicket.adminTab') }}</h2>
|
|
<p class="mt-0.5 text-xs text-neutral-400">{{ projectName }}</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-neutral-600"
|
|
@click="close"
|
|
>
|
|
<Icon name="mdi:close" size="20" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="flex items-center gap-3 border-b border-neutral-100 px-5 py-3">
|
|
<select
|
|
v-model="filterStatus"
|
|
class="rounded-lg border border-neutral-300 px-3 py-1.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
>
|
|
<option :value="null">{{ $t('clientTicket.allStatuses') }}</option>
|
|
<option value="new">{{ $t('clientTicket.status.new') }}</option>
|
|
<option value="in_progress">{{ $t('clientTicket.status.in_progress') }}</option>
|
|
<option value="done">{{ $t('clientTicket.status.done') }}</option>
|
|
<option value="rejected">{{ $t('clientTicket.status.rejected') }}</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1 overflow-y-auto px-5 py-4">
|
|
<div v-if="isLoading" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('common.loading') }}
|
|
</div>
|
|
|
|
<div v-else-if="filteredTickets.length === 0" class="py-8 text-center text-sm text-neutral-400">
|
|
{{ $t('clientTicket.noTickets') }}
|
|
</div>
|
|
|
|
<div v-else class="space-y-2">
|
|
<div
|
|
v-for="ticket in filteredTickets"
|
|
:key="ticket.id"
|
|
class="rounded-lg border border-neutral-200 bg-white"
|
|
>
|
|
<!-- Ticket row -->
|
|
<div
|
|
class="flex cursor-pointer items-start justify-between gap-3 p-3 transition-colors hover:bg-neutral-50"
|
|
@click="toggleExpand(ticket.id)"
|
|
>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-xs font-bold text-primary-500">CT-{{ String(ticket.number).padStart(3, '0') }}</span>
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
|
|
:class="typeBadgeClass(ticket.type)"
|
|
>
|
|
{{ $t(`clientTicket.type.${ticket.type}`) }}
|
|
</span>
|
|
<span
|
|
class="rounded-full px-2 py-0.5 text-xs font-semibold"
|
|
:class="statusBadgeClass(ticket.status)"
|
|
>
|
|
{{ $t(`clientTicket.status.${ticket.status}`) }}
|
|
</span>
|
|
</div>
|
|
<p class="mt-1 text-sm font-semibold text-neutral-900 leading-snug">{{ ticket.title }}</p>
|
|
<p class="mt-0.5 text-xs text-neutral-400">{{ formatDate(ticket.createdAt) }}</p>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
class="rounded p-1 text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-primary-500"
|
|
:title="$t('clientTicket.changeStatus')"
|
|
@click.stop="openStatusChange(ticket)"
|
|
>
|
|
<Icon name="mdi:swap-horizontal" size="16" />
|
|
</button>
|
|
<Icon
|
|
:name="expandedId === ticket.id ? 'mdi:chevron-up' : 'mdi:chevron-down'"
|
|
size="18"
|
|
class="text-neutral-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expanded details -->
|
|
<div v-if="expandedId === ticket.id" class="border-t border-neutral-100 px-3 py-3">
|
|
<p class="text-sm text-neutral-600 whitespace-pre-wrap">{{ ticket.description }}</p>
|
|
<div v-if="ticket.url" class="mt-2">
|
|
<a
|
|
:href="ticket.url"
|
|
target="_blank"
|
|
class="text-xs text-primary-500 underline hover:text-primary-600"
|
|
>
|
|
{{ ticket.url }}
|
|
</a>
|
|
</div>
|
|
<div v-if="ticket.statusComment" class="mt-2 rounded-lg bg-neutral-50 p-2 text-xs text-neutral-500">
|
|
{{ ticket.statusComment }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
|
|
<!-- Status change modal -->
|
|
<Teleport v-if="statusModalOpen" to="body">
|
|
<Transition name="ct-modal" appear>
|
|
<div class="fixed inset-0 z-[60] flex items-center justify-center p-4">
|
|
<div
|
|
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
|
|
@click="statusModalOpen = false"
|
|
/>
|
|
<div class="relative z-10 w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl">
|
|
<h3 class="text-lg font-bold text-neutral-900">{{ $t('clientTicket.changeStatus') }}</h3>
|
|
<p v-if="statusTarget" class="mt-1 text-sm text-neutral-500">
|
|
CT-{{ String(statusTarget.number).padStart(3, '0') }} — {{ statusTarget.title }}
|
|
</p>
|
|
|
|
<div class="mt-4">
|
|
<label class="mb-1 block text-sm font-medium text-neutral-700">Nouveau statut</label>
|
|
<select
|
|
v-model="newStatus"
|
|
class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
|
>
|
|
<option :value="null" disabled>—</option>
|
|
<option
|
|
v-for="s in availableStatusTransitions"
|
|
:key="s.value"
|
|
:value="s.value"
|
|
>
|
|
{{ s.label }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="newStatus === 'rejected'" class="mt-4">
|
|
<MalioInputTextArea
|
|
v-model="statusComment"
|
|
:label="$t('clientTicket.statusComment')"
|
|
:size="3"
|
|
/>
|
|
<p v-if="rejectionError" class="mt-1 text-xs text-red-500">
|
|
{{ $t('clientTicket.rejectionRequired') }}
|
|
</p>
|
|
</div>
|
|
|
|
<div class="mt-6 flex justify-end gap-3">
|
|
<button
|
|
class="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 transition-colors hover:bg-neutral-50"
|
|
@click="statusModalOpen = false"
|
|
>
|
|
{{ $t('common.cancel') }}
|
|
</button>
|
|
<button
|
|
class="rounded-lg bg-primary-500 px-6 py-2 text-sm font-semibold text-white transition-colors hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
|
|
:disabled="isUpdatingStatus"
|
|
@click="confirmStatusChange"
|
|
>
|
|
Confirmer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { ClientTicket, ClientTicketStatus } from '~/services/dto/client-ticket'
|
|
import { useClientTicketService } from '~/services/client-tickets'
|
|
|
|
const props = defineProps<{
|
|
projectId: number
|
|
projectName: string
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const clientTicketService = useClientTicketService()
|
|
const { typeBadgeClass, statusBadgeClass, formatDate, getAvailableStatusTransitions } = useClientTicketHelpers()
|
|
|
|
const isOpen = ref(false)
|
|
const isLoading = ref(false)
|
|
const tickets = ref<ClientTicket[]>([])
|
|
const filterStatus = ref<string | null>(null)
|
|
const expandedId = ref<number | null>(null)
|
|
|
|
const totalCount = computed(() =>
|
|
tickets.value.filter(t => t.status === 'new' || t.status === 'in_progress').length
|
|
)
|
|
|
|
const filteredTickets = computed(() => {
|
|
if (!filterStatus.value) return tickets.value
|
|
return tickets.value.filter(t => t.status === filterStatus.value)
|
|
})
|
|
|
|
// Status change
|
|
const statusModalOpen = ref(false)
|
|
const statusTarget = ref<ClientTicket | null>(null)
|
|
const newStatus = ref<string | null>(null)
|
|
const statusComment = ref('')
|
|
const rejectionError = ref(false)
|
|
const isUpdatingStatus = ref(false)
|
|
|
|
const availableStatusTransitions = computed(() => {
|
|
if (!statusTarget.value) return []
|
|
return getAvailableStatusTransitions(statusTarget.value.status, t)
|
|
})
|
|
|
|
async function loadTickets() {
|
|
isLoading.value = true
|
|
try {
|
|
tickets.value = await clientTicketService.getAll({ project: props.projectId })
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
function open() {
|
|
isOpen.value = true
|
|
loadTickets()
|
|
}
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
expandedId.value = null
|
|
}
|
|
|
|
function toggleExpand(id: number) {
|
|
expandedId.value = expandedId.value === id ? null : id
|
|
}
|
|
|
|
function openStatusChange(ticket: ClientTicket) {
|
|
statusTarget.value = ticket
|
|
newStatus.value = null
|
|
statusComment.value = ''
|
|
rejectionError.value = false
|
|
statusModalOpen.value = true
|
|
}
|
|
|
|
async function confirmStatusChange() {
|
|
if (!statusTarget.value || !newStatus.value) return
|
|
|
|
if (newStatus.value === 'rejected' && !statusComment.value.trim()) {
|
|
rejectionError.value = true
|
|
return
|
|
}
|
|
|
|
isUpdatingStatus.value = true
|
|
try {
|
|
await clientTicketService.updateStatus(statusTarget.value.id, {
|
|
status: newStatus.value as ClientTicketStatus,
|
|
statusComment: newStatus.value === 'rejected' ? statusComment.value.trim() : null,
|
|
})
|
|
statusModalOpen.value = false
|
|
await loadTickets()
|
|
} finally {
|
|
isUpdatingStatus.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ct-panel-enter-active,
|
|
.ct-panel-leave-active {
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.ct-panel-enter-active > div:last-child,
|
|
.ct-panel-leave-active > div:last-child {
|
|
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
|
}
|
|
|
|
.ct-panel-enter-from,
|
|
.ct-panel-leave-to {
|
|
opacity: 0;
|
|
}
|
|
|
|
.ct-panel-enter-from > div:last-child,
|
|
.ct-panel-leave-to > div:last-child {
|
|
transform: translateX(100%);
|
|
}
|
|
|
|
.ct-modal-enter-active,
|
|
.ct-modal-leave-active {
|
|
transition: opacity 0.15s ease;
|
|
}
|
|
|
|
.ct-modal-enter-from,
|
|
.ct-modal-leave-to {
|
|
opacity: 0;
|
|
}
|
|
</style>
|