261 lines
8.9 KiB
Vue
261 lines
8.9 KiB
Vue
<template>
|
||
<main class="container mx-auto px-6 py-10 space-y-8">
|
||
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<h1 class="text-3xl font-semibold text-base-content">Catalogue des composants</h1>
|
||
<p class="text-sm text-gray-500">
|
||
Consultez et gérez tous les composants existants.
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap gap-2">
|
||
<NuxtLink to="/component/create" class="btn btn-primary btn-sm md:btn-md">
|
||
Ajouter un composant
|
||
</NuxtLink>
|
||
<NuxtLink to="/component-category" class="btn btn-outline btn-sm md:btn-md">
|
||
Gérer les catégories
|
||
</NuxtLink>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="card border border-base-200 bg-base-100 shadow-sm">
|
||
<div class="card-body space-y-4">
|
||
<header class="flex flex-col gap-2">
|
||
<h2 class="text-xl font-semibold text-base-content">Composants créés</h2>
|
||
<p class="text-sm text-base-content/70">
|
||
Retrouvez ici tous les composants enregistrés, indépendamment de leur catégorie.
|
||
</p>
|
||
</header>
|
||
|
||
<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">
|
||
<label class="w-full sm:w-72">
|
||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
|
||
<input
|
||
v-model="searchTerm"
|
||
type="text"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="Nom, référence ou catégorie…"
|
||
/>
|
||
</label>
|
||
<div class="flex items-center gap-2">
|
||
<label
|
||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||
for="component-catalog-sort"
|
||
>
|
||
Trier par
|
||
</label>
|
||
<select
|
||
id="component-catalog-sort"
|
||
v-model="sortField"
|
||
class="select select-bordered select-sm"
|
||
>
|
||
<option value="name">Nom</option>
|
||
<option value="createdAt">Date de création</option>
|
||
</select>
|
||
</div>
|
||
<div class="flex items-center gap-2">
|
||
<label
|
||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||
for="component-catalog-dir"
|
||
>
|
||
Ordre
|
||
</label>
|
||
<select
|
||
id="component-catalog-dir"
|
||
v-model="sortDirection"
|
||
class="select select-bordered select-sm"
|
||
>
|
||
<option value="asc">Ascendant</option>
|
||
<option value="desc">Descendant</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<p class="text-xs text-base-content/50 lg:text-right">
|
||
{{ visibleComposants.length }} / {{ composantsTotal }} résultat{{ visibleComposants.length > 1 ? 's' : '' }}
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="loadingComposants" class="flex justify-center py-8">
|
||
<span class="loading loading-spinner" aria-hidden="true" />
|
||
</div>
|
||
|
||
<p v-else-if="!composantsTotal" class="text-sm text-base-content/70">
|
||
Aucun composant n'a encore été créé.
|
||
</p>
|
||
|
||
<p v-else-if="!visibleComposants.length" class="text-sm text-base-content/70">
|
||
Aucun composant ne correspond à votre recherche.
|
||
</p>
|
||
|
||
<div v-else class="overflow-x-auto">
|
||
<table class="table table-sm md:table-md">
|
||
<thead>
|
||
<tr>
|
||
<th class="w-24">Aperçu</th>
|
||
<th>Nom</th>
|
||
<th>Catégorie</th>
|
||
<th>Référence</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="component in visibleComposants" :key="component.id">
|
||
<td class="align-middle">
|
||
<DocumentThumbnail
|
||
:document="resolvePrimaryDocument(component)"
|
||
:alt="resolvePreviewAlt(component)"
|
||
/>
|
||
</td>
|
||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||
<td>{{ component.typeComposant?.name || '—' }}</td>
|
||
<td>{{ component.reference || '—' }}</td>
|
||
<td>
|
||
<div class="flex items-center gap-2">
|
||
<NuxtLink
|
||
:to="`/component/${component.id}/edit`"
|
||
class="btn btn-ghost btn-xs"
|
||
>
|
||
Modifier
|
||
</NuxtLink>
|
||
<button
|
||
type="button"
|
||
class="btn btn-error btn-xs"
|
||
:disabled="loadingComposants"
|
||
@click="handleDeleteComponent(component)"
|
||
>
|
||
Supprimer
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { useComposants } from '~/composables/useComposants'
|
||
import { useToast } from '~/composables/useToast'
|
||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||
|
||
const { showError } = useToast()
|
||
const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||
const composantsList = computed(() => composants.value || [])
|
||
const composantsTotal = computed(() => composantsList.value.length)
|
||
|
||
const searchTerm = ref('')
|
||
const sortField = ref<'name' | 'createdAt'>('name')
|
||
const sortDirection = ref<'asc' | 'desc'>('asc')
|
||
|
||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||
if (!documents.length) {
|
||
return null
|
||
}
|
||
const normalized = documents.filter((doc) => doc && typeof doc === 'object')
|
||
const withPath = normalized.filter((doc) => doc?.path)
|
||
const pdf = withPath.find((doc) => isPdfDocument(doc))
|
||
if (pdf) {
|
||
return pdf
|
||
}
|
||
const image = withPath.find((doc) => isImageDocument(doc))
|
||
if (image) {
|
||
return image
|
||
}
|
||
return withPath[0] ?? normalized[0] ?? null
|
||
}
|
||
|
||
const resolvePreviewAlt = (component: Record<string, any>) => {
|
||
const parts = [component?.name, component?.reference].filter(Boolean)
|
||
if (parts.length) {
|
||
return `Aperçu du document de ${parts.join(' – ')}`
|
||
}
|
||
return 'Aperçu du document'
|
||
}
|
||
|
||
const resolveComparableName = (component: Record<string, any>) => {
|
||
const toComparable = (value?: string | null) =>
|
||
(value ?? '').toString().trim().toLowerCase()
|
||
|
||
return (
|
||
toComparable(component?.name) ||
|
||
toComparable(component?.reference) ||
|
||
toComparable(component?.id)
|
||
)
|
||
}
|
||
|
||
const resolveComparableDate = (component: Record<string, any>) => {
|
||
const raw = component?.createdAt ?? component?.created_at ?? null
|
||
if (!raw) {
|
||
return 0
|
||
}
|
||
const parsed = new Date(raw).getTime()
|
||
return Number.isNaN(parsed) ? 0 : parsed
|
||
}
|
||
|
||
const visibleComposants = computed(() => {
|
||
const term = searchTerm.value.trim().toLowerCase()
|
||
const source = composantsList.value || []
|
||
|
||
const filtered = term
|
||
? source.filter((component) => {
|
||
const name = (component?.name || '').toLowerCase()
|
||
const reference = (component?.reference || '').toLowerCase()
|
||
const category = (component?.typeComposant?.name || '').toLowerCase()
|
||
return (
|
||
name.includes(term) ||
|
||
reference.includes(term) ||
|
||
category.includes(term)
|
||
)
|
||
})
|
||
: [...source]
|
||
|
||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||
|
||
return filtered.sort((a, b) => {
|
||
if (sortField.value === 'name') {
|
||
return (
|
||
resolveComparableName(a).localeCompare(
|
||
resolveComparableName(b),
|
||
'fr',
|
||
{ sensitivity: 'base' }
|
||
) * direction
|
||
)
|
||
}
|
||
|
||
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
|
||
})
|
||
})
|
||
|
||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||
const hasLinkedElements =
|
||
(component?.machineLinks?.length ?? 0) > 0 ||
|
||
(component?.documents?.length ?? 0) > 0 ||
|
||
(component?.customFieldValues?.length ?? 0) > 0
|
||
|
||
if (hasLinkedElements) {
|
||
showError('Impossible de supprimer ce composant car il possède des éléments liés.')
|
||
return
|
||
}
|
||
|
||
const componentName = component?.name || 'ce composant'
|
||
const confirmed = window.confirm(`Voulez-vous vraiment supprimer ${componentName} ?`)
|
||
if (!confirmed) {
|
||
return
|
||
}
|
||
|
||
await deleteComposant(component.id)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadComposants()
|
||
})
|
||
</script>
|