Add CommentSection component for inline comments on entity detail pages (machines, pieces, composants, products, categories, skeleton types). Add dedicated /comments page with filters, pagination and clickable links. Add unresolved count badge on avatar and in profile dropdown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
332 lines
10 KiB
Vue
332 lines
10 KiB
Vue
<template>
|
|
<main class="container mx-auto px-6 py-10 space-y-8">
|
|
<header>
|
|
<h1 class="text-3xl font-semibold text-base-content">
|
|
Commentaires
|
|
</h1>
|
|
<p class="text-sm text-gray-500">
|
|
Liste de tous les commentaires et tickets ouverts sur les fiches.
|
|
</p>
|
|
</header>
|
|
|
|
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
|
<div class="card-body space-y-4">
|
|
<!-- Filtres -->
|
|
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
|
<div class="flex items-center gap-2">
|
|
<label
|
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
|
for="comment-status"
|
|
>
|
|
Statut
|
|
</label>
|
|
<select
|
|
id="comment-status"
|
|
v-model="statusFilter"
|
|
class="select select-bordered select-sm"
|
|
@change="handleFilterChange"
|
|
>
|
|
<option value="open">
|
|
Ouverts
|
|
</option>
|
|
<option value="resolved">
|
|
Résolus
|
|
</option>
|
|
<option value="">
|
|
Tous
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<label
|
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
|
for="comment-entity-type"
|
|
>
|
|
Type
|
|
</label>
|
|
<select
|
|
id="comment-entity-type"
|
|
v-model="entityTypeFilter"
|
|
class="select select-bordered select-sm"
|
|
@change="handleFilterChange"
|
|
>
|
|
<option value="">
|
|
Tous
|
|
</option>
|
|
<option value="machine">
|
|
Machine
|
|
</option>
|
|
<option value="piece">
|
|
Pièce
|
|
</option>
|
|
<option value="composant">
|
|
Composant
|
|
</option>
|
|
<option value="product">
|
|
Produit
|
|
</option>
|
|
<option value="piece_category">
|
|
Catégorie pièce
|
|
</option>
|
|
<option value="component_category">
|
|
Catégorie composant
|
|
</option>
|
|
<option value="product_category">
|
|
Catégorie produit
|
|
</option>
|
|
<option value="machine_skeleton">
|
|
Squelette machine
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<label
|
|
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
|
for="comment-per-page"
|
|
>
|
|
Par page
|
|
</label>
|
|
<select
|
|
id="comment-per-page"
|
|
v-model.number="itemsPerPage"
|
|
class="select select-bordered select-sm"
|
|
@change="handleFilterChange"
|
|
>
|
|
<option :value="20">
|
|
20
|
|
</option>
|
|
<option :value="50">
|
|
50
|
|
</option>
|
|
<option :value="100">
|
|
100
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<p class="text-xs text-base-content/50 lg:text-right">
|
|
{{ comments.length }} / {{ total }} résultat{{ total > 1 ? 's' : '' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="loadingList" class="flex justify-center py-8">
|
|
<span class="loading loading-spinner" aria-hidden="true" />
|
|
</div>
|
|
|
|
<!-- Empty states -->
|
|
<p v-else-if="!comments.length" class="text-sm text-base-content/70 py-4">
|
|
Aucun commentaire trouvé.
|
|
</p>
|
|
|
|
<!-- Table -->
|
|
<template v-else>
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-sm md:table-md">
|
|
<thead>
|
|
<tr>
|
|
<th>Contenu</th>
|
|
<th>Type</th>
|
|
<th>Item</th>
|
|
<th>Auteur</th>
|
|
<th>Date</th>
|
|
<th>Statut</th>
|
|
<th v-if="canEdit">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr
|
|
v-for="comment in comments"
|
|
:key="comment.id"
|
|
class="hover"
|
|
>
|
|
<td class="max-w-xs">
|
|
<span class="line-clamp-2 text-sm">{{ comment.content }}</span>
|
|
</td>
|
|
<td>
|
|
<span class="badge badge-outline badge-sm">
|
|
{{ entityTypeLabel(comment.entityType) }}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<NuxtLink
|
|
v-if="getEntityRoute(comment)"
|
|
:to="getEntityRoute(comment)!"
|
|
class="link link-primary text-sm font-medium"
|
|
>
|
|
{{ comment.entityName || comment.entityId }}
|
|
</NuxtLink>
|
|
<span v-else class="text-sm">
|
|
{{ comment.entityName || comment.entityId }}
|
|
</span>
|
|
</td>
|
|
<td class="text-sm">
|
|
{{ comment.authorName }}
|
|
</td>
|
|
<td class="text-sm whitespace-nowrap">
|
|
{{ formatCommentDate(comment.createdAt) }}
|
|
</td>
|
|
<td>
|
|
<span
|
|
class="badge badge-sm"
|
|
:class="comment.status === 'open' ? 'badge-warning' : 'badge-success'"
|
|
>
|
|
{{ comment.status === 'open' ? 'Ouvert' : 'Résolu' }}
|
|
</span>
|
|
</td>
|
|
<td v-if="canEdit" @click.stop>
|
|
<button
|
|
v-if="comment.status === 'open'"
|
|
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>
|
|
<span v-else class="text-xs text-base-content/50">
|
|
{{ comment.resolvedByName }}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="totalPages > 1" class="flex justify-center gap-2 pt-2">
|
|
<button
|
|
class="btn btn-sm"
|
|
:disabled="page <= 1"
|
|
@click="goToPage(page - 1)"
|
|
>
|
|
Précédent
|
|
</button>
|
|
<span class="flex items-center text-sm text-base-content/70">
|
|
Page {{ page }} / {{ totalPages }}
|
|
</span>
|
|
<button
|
|
class="btn btn-sm"
|
|
:disabled="page >= totalPages"
|
|
@click="goToPage(page + 1)"
|
|
>
|
|
Suivant
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useComments, type Comment } from '~/composables/useComments'
|
|
import { usePermissions } from '~/composables/usePermissions'
|
|
import IconLucideCheck from '~icons/lucide/check'
|
|
|
|
const { canEdit } = usePermissions()
|
|
const {
|
|
loading,
|
|
fetchAllComments,
|
|
resolveComment,
|
|
} = useComments()
|
|
|
|
const comments = ref<Comment[]>([])
|
|
const total = ref(0)
|
|
const page = ref(1)
|
|
const itemsPerPage = ref(20)
|
|
const statusFilter = ref('open')
|
|
const entityTypeFilter = ref('')
|
|
const loadingList = ref(false)
|
|
|
|
const totalPages = computed(() =>
|
|
Math.max(1, Math.ceil(total.value / itemsPerPage.value)),
|
|
)
|
|
|
|
const ENTITY_TYPE_LABELS: Record<string, string> = {
|
|
machine: 'Machine',
|
|
piece: 'Pièce',
|
|
composant: 'Composant',
|
|
product: 'Produit',
|
|
piece_category: 'Cat. pièce',
|
|
component_category: 'Cat. composant',
|
|
product_category: 'Cat. produit',
|
|
machine_skeleton: 'Squelette',
|
|
}
|
|
|
|
const entityTypeLabel = (type: string): string =>
|
|
ENTITY_TYPE_LABELS[type] ?? type
|
|
|
|
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 loadComments = async () => {
|
|
loadingList.value = true
|
|
const result = await fetchAllComments({
|
|
status: statusFilter.value || undefined,
|
|
entityType: entityTypeFilter.value || undefined,
|
|
page: page.value,
|
|
itemsPerPage: itemsPerPage.value,
|
|
})
|
|
if (result.success) {
|
|
comments.value = result.data ?? []
|
|
total.value = result.total ?? 0
|
|
}
|
|
loadingList.value = false
|
|
}
|
|
|
|
const handleFilterChange = () => {
|
|
page.value = 1
|
|
loadComments()
|
|
}
|
|
|
|
const goToPage = (p: number) => {
|
|
page.value = p
|
|
loadComments()
|
|
}
|
|
|
|
const handleResolve = async (commentId: string) => {
|
|
const result = await resolveComment(commentId)
|
|
if (result.success) {
|
|
await loadComments()
|
|
}
|
|
}
|
|
|
|
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
|
|
machine: (id: string) => `/machine/${id}`,
|
|
piece: (id: string) => `/pieces/${id}/edit`,
|
|
composant: (id: string) => `/component/${id}/edit`,
|
|
product: (id: string) => `/product/${id}/edit`,
|
|
piece_category: (id: string) => `/piece-category/${id}/edit`,
|
|
component_category: (id: string) => `/component-category/${id}/edit`,
|
|
product_category: (id: string) => `/product-category/${id}/edit`,
|
|
machine_skeleton: (id: string) => `/type/${id}`,
|
|
}
|
|
|
|
const getEntityRoute = (comment: Comment): string | null => {
|
|
const builder = ENTITY_ROUTE_MAP[comment.entityType]
|
|
return builder ? builder(comment.entityId) : null
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadComments()
|
|
})
|
|
</script>
|