Merges the full git history of Inventory_frontend into the monorepo under frontend/. Removes the submodule in favor of a unified repo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
271 lines
8.4 KiB
Vue
271 lines
8.4 KiB
Vue
<template>
|
|
<div class="space-y-4">
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
|
<IconLucideMessageSquare class="w-5 h-5" />
|
|
Commentaires
|
|
<span v-if="openComments.length" class="badge badge-warning badge-sm">
|
|
{{ openComments.length }}
|
|
</span>
|
|
</h3>
|
|
<button
|
|
v-if="showResolved && resolvedComments.length"
|
|
type="button"
|
|
class="btn btn-ghost btn-xs"
|
|
@click="showResolvedList = !showResolvedList"
|
|
>
|
|
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Formulaire d'ajout -->
|
|
<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 -->
|
|
<div v-if="loadingComments" class="flex justify-center py-4">
|
|
<span class="loading loading-spinner loading-sm" />
|
|
</div>
|
|
|
|
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
|
|
Aucun commentaire ouvert.
|
|
</div>
|
|
|
|
<div v-else class="space-y-3">
|
|
<div
|
|
v-for="comment in openComments"
|
|
:key="comment.id"
|
|
class="bg-base-200 rounded-lg p-3 space-y-2"
|
|
>
|
|
<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">
|
|
<span>
|
|
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
|
|
</span>
|
|
<div v-if="canEdit" class="flex gap-1">
|
|
<button
|
|
type="button"
|
|
class="btn btn-success btn-xs gap-1"
|
|
:disabled="loading"
|
|
@click="handleResolve(comment.id)"
|
|
>
|
|
<IconLucideCheck class="w-3 h-3" />
|
|
Résoudre
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="btn btn-ghost btn-xs text-error"
|
|
:disabled="loading"
|
|
@click="handleDelete(comment.id)"
|
|
>
|
|
<IconLucideTrash2 class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Commentaires résolus -->
|
|
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
|
|
<div class="divider text-xs text-base-content/40">
|
|
Résolus
|
|
</div>
|
|
<div
|
|
v-for="comment in resolvedComments"
|
|
:key="comment.id"
|
|
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">
|
|
Résolu par {{ comment.resolvedByName }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
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
|
|
entityId: string
|
|
entityName?: string
|
|
showResolved?: boolean
|
|
}>()
|
|
|
|
const { canEdit } = usePermissions()
|
|
const {
|
|
loading,
|
|
fetchComments,
|
|
createComment,
|
|
resolveComment,
|
|
deleteComment,
|
|
} = useComments()
|
|
|
|
const comments = ref<Comment[]>([])
|
|
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'),
|
|
)
|
|
|
|
const resolvedComments = computed(() =>
|
|
comments.value.filter(c => c.status === 'resolved'),
|
|
)
|
|
|
|
const formatCommentDate = (dateStr: string): string => {
|
|
const date = new Date(dateStr)
|
|
if (Number.isNaN(date.getTime())) return '—'
|
|
return new Intl.DateTimeFormat('fr-FR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).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([
|
|
fetchComments(props.entityType, props.entityId, 'open'),
|
|
props.showResolved
|
|
? fetchComments(props.entityType, props.entityId, 'resolved')
|
|
: Promise.resolve({ success: true, data: [] as Comment[] }),
|
|
])
|
|
const open = openResult.success ? (openResult.data ?? []) : []
|
|
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
|
|
comments.value = [...open, ...resolved]
|
|
loadingComments.value = false
|
|
}
|
|
|
|
const handleSubmit = async () => {
|
|
const content = newContent.value.trim()
|
|
if (!content) return
|
|
submitting.value = true
|
|
const result = await createComment(
|
|
props.entityType,
|
|
props.entityId,
|
|
content,
|
|
props.entityName,
|
|
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
|
|
)
|
|
submitting.value = false
|
|
if (result.success) {
|
|
newContent.value = ''
|
|
selectedFiles.value = []
|
|
await loadComments()
|
|
}
|
|
}
|
|
|
|
const handleResolve = async (commentId: string) => {
|
|
const result = await resolveComment(commentId)
|
|
if (result.success) {
|
|
await loadComments()
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (commentId: string) => {
|
|
const result = await deleteComment(commentId)
|
|
if (result.success) {
|
|
comments.value = comments.value.filter(c => c.id !== commentId)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (props.entityId) {
|
|
loadComments()
|
|
}
|
|
})
|
|
</script>
|