feat(comments) : add file attachments UI for comments

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-03-24 09:10:19 +01:00
parent aebe7ed586
commit 10ad7b7f41
4 changed files with 180 additions and 43 deletions

View File

@@ -0,0 +1,55 @@
<template>
<div v-if="documents.length" class="space-y-1 mt-2">
<div
v-for="doc in documents"
:key="doc.id"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-2 py-1.5 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<component
:is="documentIcon(doc).component"
class="w-4 h-4 flex-shrink-0"
:class="documentIcon(doc).colorClass"
/>
<span class="truncate">{{ doc.name || doc.filename }}</span>
<span class="text-base-content/40 flex-shrink-0">{{ formatSize(doc.size) }}</span>
</div>
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(doc)"
:title="canPreviewDocument(doc) ? 'Consulter' : 'Aperçu non disponible'"
@click="openPreview(doc)"
>
Consulter
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
@click="downloadDocument(doc)"
>
Télécharger
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { CommentDocument } from '~/composables/useComments'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatSize, documentIcon, downloadDocument } from '~/shared/utils/documentDisplayUtils'
defineProps<{
documents: CommentDocument[]
}>()
const openPreview = (doc: CommentDocument) => {
if (!canPreviewDocument(doc)) return
// Open file URL in new tab for preview
if (doc.fileUrl) {
window.open(doc.fileUrl, '_blank')
}
}
</script>

View File

@@ -19,24 +19,55 @@
</div>
<!-- Formulaire d'ajout -->
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
:disabled="submitting"
@keydown.ctrl.enter="handleSubmit"
/>
<button
type="button"
class="btn btn-primary btn-sm self-end"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
<div class="space-y-2">
<div class="flex gap-2">
<textarea
v-model="newContent"
class="textarea textarea-bordered flex-1 text-sm"
rows="2"
placeholder="Ajouter un commentaire..."
:disabled="submitting"
@keydown.ctrl.enter="handleSubmit"
/>
<div class="flex flex-col gap-1 self-end">
<label
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
data-tip="Joindre des fichiers"
>
<IconLucidePaperclip class="w-4 h-4" />
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="handleFilesSelected"
/>
</label>
<button
type="button"
class="btn btn-primary btn-sm btn-square"
:disabled="!newContent.trim() || submitting"
@click="handleSubmit"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
<IconLucideSend v-else class="w-4 h-4" />
</button>
</div>
</div>
<!-- Selected files preview -->
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
<span
v-for="(file, i) in selectedFiles"
:key="i"
class="badge badge-sm badge-outline gap-1"
>
<IconLucideFile class="w-3 h-3" />
{{ file.name }}
<button type="button" class="ml-1" @click="removeFile(i)">
<IconLucideX class="w-3 h-3" />
</button>
</span>
</div>
</div>
<!-- Liste des commentaires ouverts -->
@@ -57,6 +88,8 @@
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<!-- Documents attachés -->
<CommentDocumentList :documents="getDocuments(comment)" />
</div>
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
@@ -97,6 +130,8 @@
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
>
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<!-- Documents attachés (résolus) -->
<CommentDocumentList :documents="getDocuments(comment)" />
<div class="flex items-center justify-between text-xs text-base-content/50">
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
<span v-if="comment.resolvedByName">
@@ -110,12 +145,16 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useComments, type Comment } from '~/composables/useComments'
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
import { usePermissions } from '~/composables/usePermissions'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import IconLucideMessageSquare from '~icons/lucide/message-square'
import IconLucideSend from '~icons/lucide/send'
import IconLucideCheck from '~icons/lucide/check'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import IconLucidePaperclip from '~icons/lucide/paperclip'
import IconLucideFile from '~icons/lucide/file'
import IconLucideX from '~icons/lucide/x'
const props = defineProps<{
entityType: string
@@ -138,6 +177,11 @@ const newContent = ref('')
const submitting = ref(false)
const loadingComments = ref(false)
const showResolvedList = ref(false)
const selectedFiles = ref<File[]>([])
const fileInputRef = ref<HTMLInputElement | null>(null)
const getDocuments = (comment: Comment): CommentDocument[] =>
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
const openComments = computed(() =>
comments.value.filter(c => c.status === 'open'),
@@ -159,6 +203,18 @@ const formatCommentDate = (dateStr: string): string => {
}).format(date)
}
const handleFilesSelected = (e: Event) => {
const input = e.target as HTMLInputElement
if (input.files) {
selectedFiles.value.push(...Array.from(input.files))
}
input.value = ''
}
const removeFile = (index: number) => {
selectedFiles.value.splice(index, 1)
}
const loadComments = async () => {
loadingComments.value = true
const [openResult, resolvedResult] = await Promise.all([
@@ -182,10 +238,12 @@ const handleSubmit = async () => {
props.entityId,
content,
props.entityName,
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
)
submitting.value = false
if (result.success) {
newContent.value = ''
selectedFiles.value = []
await loadComments()
}
}

View File

@@ -3,6 +3,18 @@ import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface CommentDocument {
id: string
name: string
filename: string
mimeType: string
size: number
type: string
fileUrl: string
downloadUrl: string
createdAt: string
}
export interface Comment {
id: string
content: string
@@ -17,6 +29,7 @@ export interface Comment {
resolvedAt?: string | null
createdAt: string
updatedAt: string
documents?: CommentDocument[]
}
interface CommentResult {
@@ -33,7 +46,7 @@ interface CommentListResult {
}
export function useComments() {
const { get, post, patch, delete: del } = useApi()
const { get, post, patch, postFormData, delete: del } = useApi()
const { showSuccess, showError } = useToast()
const loading = ref(false)
@@ -44,16 +57,9 @@ export function useComments() {
): Promise<CommentListResult> => {
loading.value = true
try {
const params = new URLSearchParams({
entityType,
entityId,
status,
'order[createdAt]': 'desc',
itemsPerPage: '200',
})
const result = await get(`/comments?${params.toString()}`)
const result = await get<Comment[]>(`/comments/by-entity/${entityType}/${entityId}?status=${status}`)
if (result.success) {
const items = extractCollection<Comment>(result.data)
const items = (result.data ?? []) as Comment[]
return { success: true, data: items }
}
return { success: false, error: result.error }
@@ -80,18 +86,15 @@ export function useComments() {
if (options.status) params.set('status', options.status)
if (options.entityType) params.set('entityType', options.entityType)
if (options.entityName) params.set('entityName', options.entityName)
const sortField = options.orderBy || 'createdAt'
const sortDir = options.orderDir || 'desc'
params.set(`order[${sortField}]`, sortDir)
params.set('sort', options.orderBy || 'createdAt')
params.set('direction', options.orderDir || 'desc')
params.set('itemsPerPage', String(options.itemsPerPage || 30))
params.set('page', String(options.page || 1))
const result = await get(`/comments?${params.toString()}`)
if (result.success) {
const items = extractCollection<Comment>(result.data)
const raw = result.data as Record<string, unknown> | null
const total = Number(raw?.['hydra:totalItems'] ?? raw?.totalItems ?? items.length)
return { success: true, data: items, total }
const result = await get<{ items: Comment[]; total: number }>(`/comments/search/list?${params.toString()}`)
if (result.success && result.data) {
const data = result.data as { items: Comment[]; total: number }
return { success: true, data: data.items, total: data.total }
}
return { success: false, error: result.error }
} catch (error) {
@@ -107,12 +110,26 @@ export function useComments() {
entityId: string,
content: string,
entityName?: string,
files?: File[],
): Promise<CommentResult> => {
loading.value = true
try {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
const result = await post('/comments', payload)
let result
if (files && files.length > 0) {
const formData = new FormData()
formData.append('content', content)
formData.append('entityType', entityType)
formData.append('entityId', entityId)
if (entityName) formData.append('entityName', entityName)
for (const file of files) {
formData.append('files[]', file)
}
result = await postFormData('/comments', formData)
} else {
const payload: Record<string, string> = { entityType, entityId, content }
if (entityName) payload.entityName = entityName
result = await post('/comments', payload)
}
if (result.success) {
showSuccess('Commentaire ajouté')
return { success: true, data: result.data as Comment }

View File

@@ -73,7 +73,10 @@
</template>
<template #cell-content="{ row }">
<span class="line-clamp-2 text-sm">{{ row.content }}</span>
<div class="tooltip tooltip-top max-w-xs" :data-tip="row.content">
<span class="line-clamp-2 text-sm text-left">{{ row.content }}</span>
</div>
<CommentDocumentList :documents="getDocuments(row)" />
</template>
<template #cell-entityType="{ row }">
@@ -132,7 +135,8 @@
<script setup lang="ts">
import { ref, computed, onMounted, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComments, type Comment } from '~/composables/useComments'
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import { usePermissions } from '~/composables/usePermissions'
import { useDataTable } from '~/composables/useDataTable'
import IconLucideCheck from '~icons/lucide/check'
@@ -148,6 +152,9 @@ const comments = ref<Comment[]>([])
const total = ref(0)
const loadingList = ref(true)
const getDocuments = (comment: Comment): CommentDocument[] =>
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
const table = useDataTable(
{ fetchData: loadComments },
{