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>
77 lines
2.9 KiB
Vue
77 lines
2.9 KiB
Vue
<template>
|
|
<div v-if="documents.length" class="mt-3">
|
|
<p class="mb-2 text-sm font-medium text-neutral-700">
|
|
{{ $t('taskDocuments.title') }} ({{ documents.length }})
|
|
</p>
|
|
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
|
<div
|
|
v-for="doc in documents"
|
|
:key="doc.id"
|
|
class="group relative flex cursor-pointer items-center gap-2 rounded-lg border border-neutral-200 p-2 transition-colors hover:bg-neutral-50"
|
|
@click="$emit('preview', doc)"
|
|
>
|
|
<!-- Thumbnail or icon -->
|
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center overflow-hidden rounded">
|
|
<img
|
|
v-if="isImage(doc.mimeType)"
|
|
:src="getDownloadUrl(doc.id)"
|
|
:alt="doc.originalName"
|
|
class="h-10 w-10 object-cover"
|
|
/>
|
|
<Icon
|
|
v-else
|
|
:name="getIconForMime(doc.mimeType)"
|
|
class="h-6 w-6 text-neutral-400"
|
|
/>
|
|
</div>
|
|
|
|
<!-- File info -->
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-xs font-medium text-neutral-700">{{ doc.originalName }}</p>
|
|
<p class="text-xs text-neutral-400">{{ formatFileSize(doc.size) }}</p>
|
|
</div>
|
|
|
|
<!-- Delete button -->
|
|
<button
|
|
v-if="isAdmin"
|
|
class="absolute right-1 top-1 hidden rounded p-0.5 text-neutral-400 transition-colors hover:bg-red-50 hover:text-red-500 group-hover:block"
|
|
@click.stop="$emit('delete', doc)"
|
|
>
|
|
<Icon name="heroicons:x-mark" class="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { TaskDocument } from '~/services/dto/task-document'
|
|
import { useTaskDocumentService } from '~/services/task-documents'
|
|
import { formatFileSize } from '~/utils/format'
|
|
|
|
defineProps<{
|
|
documents: TaskDocument[]
|
|
isAdmin: boolean
|
|
}>()
|
|
|
|
defineEmits<{
|
|
preview: [doc: TaskDocument]
|
|
delete: [doc: TaskDocument]
|
|
}>()
|
|
|
|
const { getDownloadUrl } = useTaskDocumentService()
|
|
|
|
function isImage(mimeType: string): boolean {
|
|
return mimeType.startsWith('image/')
|
|
}
|
|
|
|
function getIconForMime(mimeType: string): string {
|
|
if (mimeType === 'application/pdf') return 'heroicons:document-text'
|
|
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'heroicons:table-cells'
|
|
if (mimeType.includes('word') || mimeType.includes('document')) return 'heroicons:document'
|
|
if (mimeType.includes('zip') || mimeType.includes('archive') || mimeType.includes('tar') || mimeType.includes('rar')) return 'heroicons:archive-box'
|
|
return 'heroicons:paper-clip'
|
|
}
|
|
|
|
</script>
|