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>
|
</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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 },
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user