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> </div>
<!-- Formulaire d'ajout --> <!-- Formulaire d'ajout -->
<div class="flex gap-2"> <div class="space-y-2">
<textarea <div class="flex gap-2">
v-model="newContent" <textarea
class="textarea textarea-bordered flex-1 text-sm" v-model="newContent"
rows="2" class="textarea textarea-bordered flex-1 text-sm"
placeholder="Ajouter un commentaire..." rows="2"
:disabled="submitting" placeholder="Ajouter un commentaire..."
@keydown.ctrl.enter="handleSubmit" :disabled="submitting"
/> @keydown.ctrl.enter="handleSubmit"
<button />
type="button" <div class="flex flex-col gap-1 self-end">
class="btn btn-primary btn-sm self-end" <label
:disabled="!newContent.trim() || submitting" class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
@click="handleSubmit" data-tip="Joindre des fichiers"
> >
<span v-if="submitting" class="loading loading-spinner loading-xs" /> <IconLucidePaperclip class="w-4 h-4" />
<IconLucideSend v-else class="w-4 h-4" /> <input
</button> 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> </div>
<!-- Liste des commentaires ouverts --> <!-- Liste des commentaires ouverts -->
@@ -57,6 +88,8 @@
<div class="flex items-start justify-between gap-2"> <div class="flex items-start justify-between gap-2">
<div class="flex-1"> <div class="flex-1">
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p> <p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
<!-- Documents attachés -->
<CommentDocumentList :documents="getDocuments(comment)" />
</div> </div>
</div> </div>
<div class="flex items-center justify-between text-xs text-base-content/60"> <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" class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
> >
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p> <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"> <div class="flex items-center justify-between text-xs text-base-content/50">
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span> <span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
<span v-if="comment.resolvedByName"> <span v-if="comment.resolvedByName">
@@ -110,12 +145,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' 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 { usePermissions } from '~/composables/usePermissions'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import IconLucideMessageSquare from '~icons/lucide/message-square' import IconLucideMessageSquare from '~icons/lucide/message-square'
import IconLucideSend from '~icons/lucide/send' import IconLucideSend from '~icons/lucide/send'
import IconLucideCheck from '~icons/lucide/check' import IconLucideCheck from '~icons/lucide/check'
import IconLucideTrash2 from '~icons/lucide/trash-2' 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<{ const props = defineProps<{
entityType: string entityType: string
@@ -138,6 +177,11 @@ const newContent = ref('')
const submitting = ref(false) const submitting = ref(false)
const loadingComments = ref(false) const loadingComments = ref(false)
const showResolvedList = 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(() => const openComments = computed(() =>
comments.value.filter(c => c.status === 'open'), comments.value.filter(c => c.status === 'open'),
@@ -159,6 +203,18 @@ const formatCommentDate = (dateStr: string): string => {
}).format(date) }).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 () => { const loadComments = async () => {
loadingComments.value = true loadingComments.value = true
const [openResult, resolvedResult] = await Promise.all([ const [openResult, resolvedResult] = await Promise.all([
@@ -182,10 +238,12 @@ const handleSubmit = async () => {
props.entityId, props.entityId,
content, content,
props.entityName, props.entityName,
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
) )
submitting.value = false submitting.value = false
if (result.success) { if (result.success) {
newContent.value = '' newContent.value = ''
selectedFiles.value = []
await loadComments() await loadComments()
} }
} }

View File

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

View File

@@ -73,7 +73,10 @@
</template> </template>
<template #cell-content="{ row }"> <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>
<template #cell-entityType="{ row }"> <template #cell-entityType="{ row }">
@@ -132,7 +135,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, type Ref } from 'vue' import { ref, computed, onMounted, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.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 { usePermissions } from '~/composables/usePermissions'
import { useDataTable } from '~/composables/useDataTable' import { useDataTable } from '~/composables/useDataTable'
import IconLucideCheck from '~icons/lucide/check' import IconLucideCheck from '~icons/lucide/check'
@@ -148,6 +152,9 @@ const comments = ref<Comment[]>([])
const total = ref(0) const total = ref(0)
const loadingList = ref(true) 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( const table = useDataTable(
{ fetchData: loadComments }, { fetchData: loadComments },
{ {