feat(documents) : generalize TaskDocumentUpload and add upload zone to ticket detail modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-15 19:42:43 +01:00
parent e16fd2053e
commit 7099f1ca95
2 changed files with 217 additions and 3 deletions

View File

@@ -0,0 +1,209 @@
<template>
<Teleport v-if="isOpen" to="body">
<Transition name="ticket-modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<!-- Backdrop -->
<div
class="absolute inset-0 bg-slate-900/40 backdrop-blur-sm"
@click="close"
/>
<!-- Modal -->
<div
class="relative z-10 flex w-full max-w-2xl flex-col overflow-hidden rounded-2xl bg-white shadow-2xl ring-1 ring-black/5"
style="max-height: min(90vh, 900px)"
>
<!-- Header -->
<div class="border-b border-neutral-100 bg-neutral-50/80 px-4 py-4 sm:px-8 sm:py-5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span
v-if="ticket"
class="rounded-md bg-primary-500 px-2.5 py-1 text-xs font-bold tracking-wide text-white"
>
CT-{{ String(ticket.number).padStart(3, '0') }}
</span>
<h2 class="text-lg font-bold tracking-tight text-neutral-900">
{{ $t('portal.ticketDetail') }}
</h2>
</div>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg text-neutral-400 transition-colors hover:bg-neutral-200/60 hover:text-neutral-600"
@click="close"
>
<Icon name="mdi:close" size="20" />
</button>
</div>
</div>
<!-- Body -->
<div v-if="ticket" class="overflow-y-auto px-4 py-4 sm:px-8 sm:py-6">
<!-- Title -->
<h3 class="text-base font-bold text-neutral-900">{{ ticket.title }}</h3>
<!-- Badges -->
<div class="mt-3 flex items-center gap-2">
<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-3 py-1 text-xs font-semibold"
:class="statusBadgeClass(ticket.status)"
>
{{ $t(`clientTicket.status.${ticket.status}`) }}
</span>
</div>
<!-- Description -->
<div class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.description') }}</p>
<p class="mt-1 whitespace-pre-wrap text-sm text-neutral-600">{{ ticket.description }}</p>
</div>
<!-- URL (if bug) -->
<div v-if="ticket.url" class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.url') }}</p>
<a
:href="ticket.url"
target="_blank"
class="mt-1 text-sm text-primary-500 underline hover:text-primary-600"
>
{{ ticket.url }}
</a>
</div>
<!-- Status comment -->
<div v-if="ticket.statusComment" class="mt-4">
<p class="text-sm font-medium text-neutral-700">{{ $t('clientTicket.statusComment') }}</p>
<p class="mt-1 whitespace-pre-wrap rounded-lg bg-neutral-50 p-3 text-sm text-neutral-600">{{ ticket.statusComment }}</p>
</div>
<!-- Documents -->
<TaskDocumentList
v-if="localDocuments.length"
:documents="localDocuments"
:is-admin="false"
@preview="openPreview"
/>
<!-- Document preview -->
<TaskDocumentPreview
:document="previewDoc"
:has-prev="previewIndex > 0"
:has-next="previewIndex < localDocuments.length - 1"
@close="previewDoc = null"
@prev="prevPreview"
@next="nextPreview"
/>
<!-- Upload zone -->
<TaskDocumentUpload
v-if="ticket"
:client-ticket-id="ticket.id"
@uploaded="refreshDocuments"
/>
<!-- Date -->
<p class="mt-6 text-xs text-neutral-400">
{{ $t('clientTicket.createdAt') }} : {{ formatDate(ticket.createdAt) }}
</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import type { ClientTicket } from '~/services/dto/client-ticket'
import type { TaskDocument } from '~/services/dto/task-document'
import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{
modelValue: boolean
ticket: ClientTicket | null
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
function close() {
isOpen.value = false
}
const { getByTicket } = useTaskDocumentService()
const { typeBadgeClass, statusBadgeClass, formatDate } = useClientTicketHelpers()
async function refreshDocuments() {
if (!props.ticket) return
localDocuments.value = await getByTicket(props.ticket.id)
}
// Document list (local copy to allow refresh)
const localDocuments = ref<TaskDocument[]>([])
watch(() => props.ticket?.documents, (docs) => {
localDocuments.value = docs ? [...docs] : []
}, { immediate: true })
// Document preview
const previewDoc = ref<TaskDocument | null>(null)
const previewIndex = computed(() => {
if (!previewDoc.value) return -1
return localDocuments.value.findIndex(d => d.id === previewDoc.value!.id)
})
function openPreview(doc: TaskDocument) {
previewDoc.value = doc
}
function prevPreview() {
if (previewIndex.value > 0) {
previewDoc.value = localDocuments.value[previewIndex.value - 1]
}
}
function nextPreview() {
if (previewIndex.value < localDocuments.value.length - 1) {
previewDoc.value = localDocuments.value[previewIndex.value + 1]
}
}
</script>
<style scoped>
.ticket-modal-enter-active,
.ticket-modal-leave-active {
transition: opacity 0.2s ease;
}
.ticket-modal-enter-active > div:last-child,
.ticket-modal-leave-active > div:last-child {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.2s ease;
}
.ticket-modal-enter-from,
.ticket-modal-leave-to {
opacity: 0;
}
.ticket-modal-enter-from > div:last-child {
transform: scale(0.95) translateY(8px);
opacity: 0;
}
.ticket-modal-leave-to > div:last-child {
transform: scale(0.97);
opacity: 0;
}
</style>

View File

@@ -49,14 +49,15 @@
import { useTaskDocumentService } from '~/services/task-documents'
const props = defineProps<{
taskId: number
taskId?: number
clientTicketId?: number
}>()
const emit = defineEmits<{
uploaded: []
}>()
const { upload: uploadFile } = useTaskDocumentService()
const { upload: uploadFile, uploadForTicket } = useTaskDocumentService()
const toast = useToast()
const { t } = useI18n()
@@ -109,7 +110,11 @@ async function processFiles(files: File[]) {
uploads.value.push(state)
try {
await uploadFile(props.taskId, file)
if (props.clientTicketId) {
await uploadForTicket(props.clientTicketId, file)
} else if (props.taskId) {
await uploadFile(props.taskId, file)
}
state.uploading = false
state.progress = 100
} catch {