feat(comments): add comment/ticket system across all entity pages
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>
This commit is contained in:
212
app/components/CommentSection.vue
Normal file
212
app/components/CommentSection.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<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="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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
<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 } from '~/composables/useComments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
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'
|
||||
|
||||
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 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 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,
|
||||
)
|
||||
submitting.value = false
|
||||
if (result.success) {
|
||||
newContent.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>
|
||||
@@ -65,6 +65,9 @@
|
||||
:class="childLinkClass(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -142,6 +145,9 @@
|
||||
:class="childLinkClass(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -166,8 +172,14 @@
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="btn btn-ghost btn-circle avatar placeholder"
|
||||
class="btn btn-ghost btn-circle avatar placeholder indicator"
|
||||
>
|
||||
<span
|
||||
v-if="unresolvedCount > 0"
|
||||
class="indicator-item badge badge-warning badge-xs"
|
||||
>
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
<div
|
||||
class="bg-secondary text-secondary-content rounded-full w-10 h-10 grid place-items-center"
|
||||
>
|
||||
@@ -193,6 +205,15 @@
|
||||
<IconLucideChevronRight class="w-4 h-4" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/comments" class="justify-between">
|
||||
Commentaires
|
||||
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
<IconLucideChevronRight v-else class="w-4 h-4" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
@@ -212,11 +233,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
import { useComments } from '~/composables/useComments'
|
||||
import IconLucideMenu from '~icons/lucide/menu'
|
||||
import IconLucideSettings from '~icons/lucide/settings'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
@@ -277,11 +299,12 @@ const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log'],
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||
{ to: '/comments', label: 'Commentaires' },
|
||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||
],
|
||||
},
|
||||
@@ -291,6 +314,24 @@ const route = useRoute()
|
||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||
const { activeProfile } = useProfileSession()
|
||||
const { isAdmin, canEdit } = usePermissions()
|
||||
const { fetchUnresolvedCount } = useComments()
|
||||
|
||||
const unresolvedCount = ref(0)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const refreshUnresolvedCount = async () => {
|
||||
if (!activeProfile.value) return
|
||||
unresolvedCount.value = await fetchUnresolvedCount()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshUnresolvedCount()
|
||||
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
})
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') {
|
||||
|
||||
184
app/composables/useComments.ts
Normal file
184
app/composables/useComments.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
import { useToast } from './useToast'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
|
||||
export interface Comment {
|
||||
id: string
|
||||
content: string
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName?: string | null
|
||||
authorId: string
|
||||
authorName: string
|
||||
status: 'open' | 'resolved'
|
||||
resolvedById?: string | null
|
||||
resolvedByName?: string | null
|
||||
resolvedAt?: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface CommentResult {
|
||||
success: boolean
|
||||
data?: Comment | Comment[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface CommentListResult {
|
||||
success: boolean
|
||||
data?: Comment[]
|
||||
total?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useComments() {
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { showSuccess, showError } = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchComments = async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
status: string = 'open',
|
||||
): 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()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection<Comment>(result.data)
|
||||
return { success: true, data: items }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllComments = async (options: {
|
||||
status?: string
|
||||
entityType?: string
|
||||
page?: number
|
||||
itemsPerPage?: number
|
||||
} = {}): Promise<CommentListResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (options.status) params.set('status', options.status)
|
||||
if (options.entityType) params.set('entityType', options.entityType)
|
||||
params.set('order[createdAt]', '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 }
|
||||
}
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createComment = async (
|
||||
entityType: string,
|
||||
entityId: string,
|
||||
content: string,
|
||||
entityName?: string,
|
||||
): 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)
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire ajouté')
|
||||
return { success: true, data: result.data as Comment }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible d\'ajouter le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveComment = async (commentId: string): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/comments/${commentId}/resolve`)
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire résolu')
|
||||
return { success: true, data: result.data as Comment }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible de résoudre le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteComment = async (commentId: string): Promise<CommentResult> => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await del(`/comments/${commentId}`)
|
||||
if (result.success) {
|
||||
showSuccess('Commentaire supprimé')
|
||||
return { success: true }
|
||||
}
|
||||
if (result.error) showError(result.error)
|
||||
return { success: false, error: result.error }
|
||||
} catch (error) {
|
||||
const err = error as Error
|
||||
showError('Impossible de supprimer le commentaire')
|
||||
return { success: false, error: err.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUnresolvedCount = async (): Promise<number> => {
|
||||
try {
|
||||
const result = await get<{ count: number }>('/comments/stats/unresolved-count')
|
||||
if (result.success && result.data) {
|
||||
return result.data.count
|
||||
}
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
fetchComments,
|
||||
fetchAllComments,
|
||||
createComment,
|
||||
resolveComment,
|
||||
deleteComment,
|
||||
fetchUnresolvedCount,
|
||||
}
|
||||
}
|
||||
331
app/pages/comments.vue
Normal file
331
app/pages/comments.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<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>
|
||||
@@ -35,6 +35,16 @@
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="component_category"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="initialData?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -512,6 +512,16 @@
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="composant"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="component?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -108,6 +108,16 @@
|
||||
@edit-piece="d.editPiece"
|
||||
@custom-field-update="d.updatePieceCustomField"
|
||||
/>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="machine"
|
||||
:entity-id="String(machineId)"
|
||||
:entity-name="d.machine.value?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
|
||||
@@ -35,6 +35,16 @@
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="piece_category"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="initialData?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -459,6 +459,16 @@
|
||||
Enregistrer les modifications
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="piece"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="piece?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -35,6 +35,16 @@
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="product_category"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="initialData?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -382,6 +382,16 @@
|
||||
<p v-if="product && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
|
||||
Merci de renseigner tous les champs personnalisés obligatoires.
|
||||
</p>
|
||||
|
||||
<!-- Comments -->
|
||||
<div class="mt-4">
|
||||
<CommentSection
|
||||
entity-type="product"
|
||||
:entity-id="String(route.params.id)"
|
||||
:entity-name="product?.name"
|
||||
show-resolved
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -127,6 +127,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commentaires -->
|
||||
<CommentSection
|
||||
entity-type="machine_skeleton"
|
||||
:entity-id="type.id"
|
||||
:entity-name="type.name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
|
||||
Reference in New Issue
Block a user