Show cascade-delete impact (documents, machine links, custom fields) in a confirmation modal instead of blocking deletion entirely. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
241 lines
9.7 KiB
Vue
241 lines
9.7 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>
|
||
|
||
<DataTable
|
||
:columns="columns"
|
||
:rows="componentRows"
|
||
:loading="loadingComposants"
|
||
:sort="table.sort.value"
|
||
:pagination="paginationState"
|
||
:column-filters="table.columnFilters.value"
|
||
:show-per-page="true"
|
||
empty-message="Aucun composant n'a encore été créé."
|
||
no-results-message="Aucun composant ne correspond à votre recherche."
|
||
@sort="table.handleSort"
|
||
@update:current-page="table.handlePageChange"
|
||
@update:per-page="table.handlePerPageChange"
|
||
@update:column-filters="table.handleColumnFiltersChange"
|
||
>
|
||
<template #toolbar>
|
||
<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="table.searchTerm.value"
|
||
type="text"
|
||
class="input input-bordered input-sm w-full mt-1"
|
||
placeholder="Nom ou référence…"
|
||
@input="table.debouncedSearch"
|
||
/>
|
||
</label>
|
||
</template>
|
||
|
||
<template #cell-preview="{ row }">
|
||
<DocumentThumbnail
|
||
:document="resolvePrimaryDocument(row.component)"
|
||
:alt="resolvePreviewAlt(row.component)"
|
||
/>
|
||
</template>
|
||
|
||
<template #cell-name="{ row }">
|
||
{{ row.component.name || 'Composant sans nom' }}
|
||
</template>
|
||
|
||
<template #cell-reference="{ row }">
|
||
{{ row.component.reference || '—' }}
|
||
</template>
|
||
|
||
<template #cell-description="{ row }">
|
||
<div v-if="row.component.description" class="group relative">
|
||
<span class="block cursor-help truncate">{{ row.component.description }}</span>
|
||
<div class="pointer-events-none invisible absolute left-0 top-full z-50 mt-1 max-w-sm overflow-hidden rounded-lg border border-base-300 bg-base-100 p-3 text-sm shadow-lg group-hover:pointer-events-auto group-hover:visible">
|
||
<p class="break-words whitespace-pre-wrap">{{ row.component.description }}</p>
|
||
</div>
|
||
</div>
|
||
<span v-else>—</span>
|
||
</template>
|
||
|
||
<template #cell-typeComposant="{ row }">
|
||
<NuxtLink
|
||
v-if="row.component.typeComposant?.id"
|
||
:to="`/component-category/${row.component.typeComposant.id}/edit`"
|
||
class="link link-hover link-primary"
|
||
>
|
||
{{ resolveComponentType(row.component) }}
|
||
</NuxtLink>
|
||
<span v-else>{{ resolveComponentType(row.component) }}</span>
|
||
</template>
|
||
|
||
<template #cell-createdAt="{ row }">
|
||
<span class="whitespace-nowrap">{{ formatDate(row.component.createdAt) }}</span>
|
||
</template>
|
||
|
||
<template #cell-actions="{ row }">
|
||
<div class="flex items-center gap-2">
|
||
<NuxtLink
|
||
:to="`/component/${row.component.id}/edit`"
|
||
class="btn btn-ghost btn-xs"
|
||
>
|
||
Modifier
|
||
</NuxtLink>
|
||
<button
|
||
v-if="canEdit"
|
||
type="button"
|
||
class="btn btn-error btn-xs"
|
||
:disabled="loadingComposants"
|
||
@click="handleDeleteComponent(row.component)"
|
||
>
|
||
Supprimer
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</DataTable>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted } from 'vue'
|
||
import DataTable from '~/components/common/DataTable.vue'
|
||
import { useComposants } from '~/composables/useComposants'
|
||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||
import { useDataTable } from '~/composables/useDataTable'
|
||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||
|
||
const { canEdit } = usePermissions()
|
||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||
|
||
const table = useDataTable(
|
||
{ fetchData: fetchComposants },
|
||
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true },
|
||
)
|
||
|
||
const columns = [
|
||
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
|
||
{ key: 'name', label: 'Nom', sortable: true },
|
||
{ key: 'reference', label: 'Référence' },
|
||
{ key: 'description', label: 'Description' },
|
||
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
|
||
{ key: 'createdAt', label: 'Date', sortable: true },
|
||
{ key: 'actions', label: 'Actions' },
|
||
]
|
||
|
||
const composantsOnPage = computed(() => componentRows.value.length)
|
||
const paginationState = table.pagination(total, composantsOnPage)
|
||
|
||
// Enrich composants with full type data
|
||
const composantsList = computed(() => {
|
||
return (composants.value || []).map((composant) => {
|
||
const typeComposant = componentTypes.value.find(t => t.id === composant.typeComposantId)
|
||
return { ...composant, typeComposant: typeComposant || composant.typeComposant || null }
|
||
})
|
||
})
|
||
|
||
const componentRows = computed(() =>
|
||
composantsList.value.map(component => ({
|
||
id: component.id,
|
||
component,
|
||
})),
|
||
)
|
||
|
||
async function fetchComposants() {
|
||
await loadComposants({
|
||
search: table.searchTerm.value,
|
||
page: table.currentPage.value,
|
||
itemsPerPage: table.itemsPerPage.value,
|
||
orderBy: table.sortField.value,
|
||
orderDir: table.sortDirection.value as 'asc' | 'desc',
|
||
typeName: table.columnFilters.value.typeComposant || undefined,
|
||
force: true,
|
||
})
|
||
}
|
||
|
||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||
if (!documents.length) return null
|
||
const normalized = documents.filter((doc: any) => doc && typeof doc === 'object')
|
||
const withPath = normalized.filter((doc: any) => doc?.fileUrl || doc?.path)
|
||
const pdf = withPath.find((doc: any) => isPdfDocument(doc))
|
||
if (pdf) return pdf
|
||
const image = withPath.find((doc: any) => 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)
|
||
return parts.length ? `Aperçu du document de ${parts.join(' – ')}` : 'Aperçu du document'
|
||
}
|
||
|
||
const resolveComponentType = (component: Record<string, any>) => {
|
||
if (component?.typeComposant?.name) return component.typeComposant.name
|
||
if (component?.typeComposantLabel) return component.typeComposantLabel
|
||
return '—'
|
||
}
|
||
|
||
const resolveDeleteImpact = (component: Record<string, any>) => {
|
||
const impacts: string[] = []
|
||
const machineLinks = Array.isArray(component?.machineLinks) ? component.machineLinks.length : component?.machineLinksCount ?? 0
|
||
const documents = Array.isArray(component?.documents) ? component.documents.length : component?.documentsCount ?? 0
|
||
const customFields = Array.isArray(component?.customFieldValues) ? component.customFieldValues.length : component?.customFieldValuesCount ?? 0
|
||
if (machineLinks > 0) impacts.push(`${machineLinks} liaison${machineLinks > 1 ? 's' : ''} machine`)
|
||
if (documents > 0) impacts.push(`${documents} document${documents > 1 ? 's' : ''}`)
|
||
if (customFields > 0) impacts.push(`${customFields} valeur${customFields > 1 ? 's' : ''} de champs personnalisés`)
|
||
return impacts
|
||
}
|
||
|
||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||
const componentName = component?.name || 'ce composant'
|
||
const impacts = resolveDeleteImpact(component)
|
||
const lines = [`Voulez-vous vraiment supprimer « ${componentName} » ?`]
|
||
if (impacts.length) {
|
||
lines.push(`Cela supprimera également :\n• ${impacts.join('\n• ')}`)
|
||
}
|
||
lines.push('Cette action est irréversible.')
|
||
const { confirm } = useConfirm()
|
||
const confirmed = await confirm({ title: 'Supprimer le composant', message: lines.join('\n\n'), dangerous: true })
|
||
if (!confirmed) return
|
||
await deleteComposant(component.id)
|
||
fetchComposants()
|
||
}
|
||
|
||
const formatDate = (dateStr: string) => {
|
||
if (!dateStr) return '—'
|
||
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' }).format(date)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await Promise.all([fetchComposants(), loadComponentTypes()])
|
||
})
|
||
</script>
|