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:
55
app/components/CommentDocumentList.vue
Normal file
55
app/components/CommentDocumentList.vue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user