fix(ui) : remove legacy edit pages and history composables, unify create/edit forms

Consolidate create and edit pages into single create pages with edit mode support.
Remove obsolete catalog pages, history composables, and fix remaining code review issues.
Include migration to relink orphaned custom fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 11:19:50 +02:00
parent cfaf234419
commit 201485552a
29 changed files with 875 additions and 2748 deletions

View File

@@ -422,16 +422,16 @@ INSERT INTO public.constructeurs (id, name, email, phone, createdat, updatedat)
--
-- Data for Name: _composantconstructeurs; Type: TABLE DATA; Schema: public; Owner: -
-- Data for Name: composant_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._composantconstructeurs (a, b) VALUES ('cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000001', 'cmgz7fd3l009y47fff1l4g0p0', 'cmgqp5dvp00014705qpkci8qc', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000002', 'cmh3jvqoa002y47zbctflkydc', 'cmhnaaoam000847s85wfwi2wm', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000003', 'cmh0d59v5000347s561ahbept', 'cmhnaaoam000847s85wfwi2wm', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000004', 'cmh0d59v5000347s561ahbept', 'cmg93n9sk000047uuwm6u20mj', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000005', 'cmkqps2h8001q1eq6k2uxopfo', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000006', 'cmkqyn2jm002m1eq6ws83lgwx', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixcc0000000000000000007', 'cl9b1583768c7c9fe6cfe93a11', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
--
@@ -461,7 +461,7 @@ INSERT INTO public.machines (id, name, reference, prix, createdat, updatedat, si
--
-- Data for Name: _machineconstructeurs; Type: TABLE DATA; Schema: public; Owner: -
-- Data for Name: machine_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
--
@@ -588,25 +588,25 @@ INSERT INTO public.pieces (id, name, reference, prix, createdat, updatedat, type
--
-- Data for Name: _piececonstructeurs; Type: TABLE DATA; Schema: public; Owner: -
-- Data for Name: piece_constructeur_links; Type: TABLE DATA; Schema: public; Owner: -
--
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv');
INSERT INTO public._piececonstructeurs (a, b) VALUES ('cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000001', 'cmizudzfy00021e2w2mtd9zv8', 'cmizu5ugx00011e2wjpr6nb3k', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000002', 'cmizv8nzu00081e2wen6ur31b', 'cmizv4lm500071e2w6xymi2p6', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000003', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcirqnh00101e2w0ht25qic', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000004', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcismo400111e2whfxnsnd3', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000005', 'cmjcixqq300141e2wqkvz0cx6', 'cmjciuk3t00121e2wxtz9o5fh', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000006', 'cmjcixqq300141e2wqkvz0cx6', 'cmjcivgex00131e2wf04n31ql', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000007', 'cmjcpdwqs00161e2wu4juy4u2', 'cmjcirqnh00101e2w0ht25qic', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000008', 'cmkqzl1oa002v1eq6erkt5544', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000009', 'cmkr0nq1a004e1eq6v6ubxlfl', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000010', 'cmkr20cpy005a1eq6nn5kmtys', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000011', 'cmkr25xz1005v1eq6i0fib4er', 'cmkqpnznr001p1eq6hdh2ept8', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000012', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9sk000047uuwm6u20mj', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000013', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9te000547uuond39s1c', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000014', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9tb000447uuuddgakar', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000015', 'cl89d9641d47f52c5385f83d5c', 'cmhaac3vo003547v7s1wv6jhv', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
INSERT INTO public.piece_constructeur_links (id, pieceid, constructeurid, supplierreference, createdat, updatedat) VALUES ('clfixpc0000000000000000016', 'cl89d9641d47f52c5385f83d5c', 'cmg93n9tm000647uu6em8thyq', NULL, '2026-01-01 00:00:00', '2026-01-01 00:00:00');
--

View File

@@ -61,11 +61,11 @@ const crumbs = computed<Crumb[]>(() => {
}
// Catalogs
else if (path.startsWith('/catalogues/composants') || path === '/component-catalog') {
else if (path.startsWith('/catalogues/composants')) {
result.push({ label: 'Composants', path: '/catalogues/composants' })
} else if (path.startsWith('/catalogues/pieces') || path === '/pieces-catalog') {
} else if (path.startsWith('/catalogues/pieces')) {
result.push({ label: 'Pièces', path: '/catalogues/pieces' })
} else if (path.startsWith('/catalogues/produits') || path === '/product-catalog') {
} else if (path.startsWith('/catalogues/produits')) {
result.push({ label: 'Produits', path: '/catalogues/produits' })
}

View File

@@ -270,6 +270,7 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideLogOut from '~icons/lucide/log-out'
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideBookOpen from '~icons/lucide/book-open'
import IconLucidePackage from '~icons/lucide/package'
import IconLucideSun from '~icons/lucide/sun'
@@ -299,6 +300,7 @@ interface NavGroup {
const simpleLinks: NavLink[] = [
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
{ to: '/doc', label: 'Documentation', icon: IconLucideBookOpen },
]
const navGroups: NavGroup[] = [
@@ -306,7 +308,7 @@ const navGroups: NavGroup[] = [
id: 'catalogues',
label: 'Catalogues',
icon: IconLucidePackage,
activePaths: ['/catalogues', '/component', '/piece', '/product', '/component-catalog', '/pieces-catalog', '/product-catalog'],
activePaths: ['/catalogues', '/component', '/piece', '/product'],
children: [
{ to: '/catalogues/composants', label: 'Composants' },
{ to: '/catalogues/pieces', label: 'Pièces' },

View File

@@ -12,7 +12,7 @@ import { extractRelationId } from '~/shared/apiRelations'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useComponentHistory } from '~/composables/useComponentHistory'
import { useEntityHistory } from '~/composables/useEntityHistory'
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
import { uniqueConstructeurIds, constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
@@ -65,7 +65,7 @@ export function useComponentEdit(componentId: string) {
loading: historyLoading,
error: historyError,
loadHistory,
} = useComponentHistory()
} = useEntityHistory('composant')
const component = ref<any | null>(null)
const loading = ref(true)

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ComponentHistoryActor = EntityHistoryActor
export type ComponentHistoryEntry = EntityHistoryEntry
export function useComponentHistory() {
return useEntityHistory('composant')
}

View File

@@ -4,7 +4,7 @@ import { useApi } from './useApi'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Composant {
id: string
@@ -51,17 +51,6 @@ const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function useComposants() {
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()

View File

@@ -1,7 +1,7 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Document {
id: string
@@ -58,13 +58,6 @@ const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') return p.totalItems
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
return fallbackLength
}
export function useDocuments() {
const { get, patch, postFormData, delete: del } = useApi()
const { showError, showSuccess } = useToast()

View File

@@ -7,7 +7,7 @@ import { useToast } from '~/composables/useToast'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { usePieceHistory } from '~/composables/usePieceHistory'
import { useEntityHistory } from '~/composables/useEntityHistory'
import { extractRelationId } from '~/shared/apiRelations'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
@@ -47,7 +47,7 @@ export function usePieceEdit(pieceId: string) {
loading: historyLoading,
error: historyError,
loadHistory,
} = usePieceHistory()
} = useEntityHistory('piece')
const piece = ref<any | null>(null)
const loading = ref(true)

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type PieceHistoryActor = EntityHistoryActor
export type PieceHistoryEntry = EntityHistoryEntry
export function usePieceHistory() {
return useEntityHistory('piece')
}

View File

@@ -4,7 +4,7 @@ import { useApi } from './useApi'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Piece {
id: string
@@ -53,17 +53,6 @@ const total = ref(0)
const loading = ref(false)
const loaded = ref(false)
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function usePieces() {
const { showSuccess } = useToast()
const { get, post, patch, delete: del } = useApi()

View File

@@ -1,12 +0,0 @@
/**
* Backward-compatible wrapper around useEntityHistory.
* Real logic lives in useEntityHistory.ts.
*/
import { useEntityHistory, type EntityHistoryActor, type EntityHistoryEntry } from './useEntityHistory'
export type ProductHistoryActor = EntityHistoryActor
export type ProductHistoryEntry = EntityHistoryEntry
export function useProductHistory() {
return useEntityHistory('product')
}

View File

@@ -5,7 +5,7 @@ import { humanizeError } from '~/shared/utils/errorMessages'
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
import { useConstructeurs, type Constructeur } from './useConstructeurs'
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
import { extractCollection } from '~/shared/utils/apiHelpers'
import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface Product {
id: string
@@ -66,17 +66,6 @@ const replaceInCache = (item: Product): boolean => {
return false
}
const extractTotal = (payload: unknown, fallbackLength: number): number => {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') {
return p.totalItems
}
if (typeof p?.['hydra:totalItems'] === 'number') {
return p['hydra:totalItems']
}
return fallbackLength
}
export function useProducts() {
const { showError } = useToast()
const { get, post, patch, delete: del } = useApi()

View File

@@ -1,8 +1,13 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6">
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Composants</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de composants.</p>
</div>
<NuxtLink v-if="canEdit" to="/component/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un composant
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Composants">
@@ -59,6 +64,10 @@
{{ row.component.reference || '—' }}
</template>
<template #cell-referenceAuto="{ row }">
{{ row.component.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.component.description" class="group relative">
<span class="block cursor-help truncate">{{ row.component.description }}</span>
@@ -169,6 +178,7 @@ const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' },
{ key: 'typeComposant', label: 'Type de composant', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },

View File

@@ -1,8 +1,13 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6">
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Pièces</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de pièces.</p>
</div>
<NuxtLink v-if="canEdit" to="/pieces/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter une pièce
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Pièces">

View File

@@ -1,8 +1,13 @@
<template>
<main class="container mx-auto px-6 py-10">
<div class="flex flex-col gap-2 mb-6">
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
<div class="flex flex-col gap-2 mb-6 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Produits</h1>
<p class="text-sm text-base-content/70">Catalogue et catégories de produits.</p>
</div>
<NuxtLink v-if="canEdit" to="/product/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un produit
</NuxtLink>
</div>
<EntityTabs v-model="activeTab" :tabs="pageTabs" aria-label="Produits">

View File

@@ -1,212 +0,0 @@
<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-end md:justify-between">
<div>
<h1 class="text-3xl font-bold text-base-content tracking-tight">Catalogue des composants</h1>
<p class="text-sm text-base-content/50 mt-1">
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-ghost btn-sm md:btn-md">
Gérer les catégories
</NuxtLink>
</div>
</header>
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<header class="flex flex-col gap-1">
<h2 class="text-xl font-bold text-base-content tracking-tight">Composants créés</h2>
<p class="text-sm text-base-content/50">
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 justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/component/${row.component.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingComposants"
@click="handleDeleteComponent(row.component)"
>
Supprimer
</button>
<NuxtLink
:to="`/component/${row.component.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</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 { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions()
const { composants, total, loadComposants, loading: loadingComposants, deleteComposant } = useComposants()
const { componentTypes, loadComponentTypes } = useComponentTypes()
const table = useDataTable(
{ fetchData: fetchComposants },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeComposant'] },
)
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 resolveComponentType = (component: Record<string, any>) => {
if (component?.typeComposant?.name) return component.typeComposant.name
if (component?.typeComposantLabel) return component.typeComposantLabel
return '—'
}
const { confirm } = useConfirm()
const handleDeleteComponent = async (component: Record<string, any>) => {
const componentName = component?.name || 'ce composant'
const message = buildDeleteMessage(componentName, resolveDeleteImpact(component))
const confirmed = await confirm({ title: 'Supprimer le composant', message, dangerous: true })
if (!confirmed) return
await deleteComposant(component.id)
fetchComposants()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchComposants(), loadComponentTypes()])
})
</script>

View File

@@ -1,452 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="componentDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du composant</p>
</div>
<div v-else-if="!component" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Composant introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver le composant demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le composant</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du composant et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<div class="flex items-center gap-2">
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md flex-1"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in componentTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<NuxtLink
v-if="selectedTypeId"
:to="`/component-category/${selectedTypeId}/edit`"
class="btn btn-ghost btn-sm"
title="Voir la catégorie"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M11 3a1 1 0 100 2h2.586l-6.293 6.293a1 1 0 101.414 1.414L15 6.414V9a1 1 0 102 0V4a1 1 0 00-1-1h-5z" />
<path d="M5 5a2 2 0 00-2 2v8a2 2 0 002 2h8a2 2 0 002-2v-3a1 1 0 10-2 0v3H5V7h3a1 1 0 000-2H5z" />
</svg>
</NuxtLink>
</div>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<div
v-if="pieceSlotEntries.length || productSlotEntries.length || subcomponentSlotEntries.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Sélections du squelette</h2>
<p class="text-xs text-base-content/70">
Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.
</p>
</header>
<div v-if="pieceSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Pièces</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in pieceSlotEntries"
:key="`piece-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<div class="flex items-start gap-2">
<div class="flex-1">
<PieceSelect
:model-value="slot.selectedPieceId"
:disabled="!canEdit || saving"
:type-piece-id="slot.typePieceId"
@update:model-value="(value) => setPieceSlotSelection(slot.slotId, value)"
/>
</div>
<div class="w-20 shrink-0">
<input
type="number"
:value="slot.quantity"
min="1"
class="input input-bordered input-sm w-full text-center"
:disabled="!canEdit || saving"
title="Quantité"
@change="(e) => setSlotQuantity(slot.slotId, Number((e.target as HTMLInputElement).value))"
>
</div>
</div>
</div>
</div>
</div>
<div v-if="productSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Produits</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in productSlotEntries"
:key="`product-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
<div v-if="subcomponentSlotEntries.length" class="space-y-2">
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="slot in subcomponentSlotEntries"
:key="`sub-slot-${slot.slotId}`"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
</label>
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</div>
</div>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce composant.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="composant"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchComponent()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="async () => { await submitEdition(); versionRefreshKey++ }">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
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>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute } from '#imports'
import { useComponentEdit } from '~/composables/useComponentEdit'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
const route = useRoute()
const { updateDocument } = useDocuments()
const { getConstructeurById } = useConstructeurs()
const versionRefreshKey = ref(0)
const {
component,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
componentDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
historyFieldLabels,
canEdit,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
structureSelections,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
submitEdition,
setSlotQuantity,
setPieceSlotSelection,
setProductSlotSelection,
setSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
fetchComponent,
} = useComponentEdit(String(route.params.id))
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
// Remove links whose ID was removed from the select
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = componentDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
componentDocuments.value[idx] = { ...componentDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script>

View File

@@ -1,212 +1,240 @@
<template>
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouvel composant</h1>
<p class="text-sm text-base-content/70">
Sélectionnez la catégorie cible puis complétez les informations du composant.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="componentTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouveau composant"
subtitle="Sélectionnez la catégorie cible puis complétez les informations du composant."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/composants"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections composant">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de composant</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="componentTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du composant</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div
v-if="structureHasRequirements"
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">
Sélection des éléments du squelette
</h2>
<p class="text-xs text-base-content/70">
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
<!-- Description -->
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description du composant (optionnel)"
rows="3"
/>
</div>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
</div>
</template>
<template #tab-structure>
<div class="space-y-6">
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedTypeStructure"
:description="selectedType.description || 'Ce squelette définit la structure et les contraintes du composant.'"
:preview-badge="formatStructurePreview(selectedTypeStructure)"
variant="component"
show-empty-state
:resolve-piece-label="resolvePieceLabel"
:resolve-product-label="resolveProductLabel"
:resolve-subcomponent-label="resolveSubcomponentLabel"
/>
<div
v-if="structureHasRequirements"
class="space-y-4 rounded-lg border border-primary/30 bg-primary/5 p-4"
>
<div class="flex items-start justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">
Sélection des éléments du squelette
</h2>
<p class="text-xs text-base-content/70">
Affectez les pièces et sous-composants concrets correspondant à la catégorie choisie.
</p>
</div>
<span
class="badge"
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
>
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
</span>
</div>
<div
v-if="structureDataLoading"
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement du catalogue de pièces, produits et composants
</div>
<ComponentStructureAssignmentNode
v-else-if="structureAssignments"
:assignment="structureAssignments"
:pieces="availablePieces"
:products="availableProducts"
:components="availableComponents"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading"
:piece-type-label-map="pieceTypeLabelMap"
:product-type-label-map="productTypeLabelMap"
:component-type-label-map="componentTypeLabelMap"
/>
<p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette.
</p>
</div>
<EmptyState
v-if="!selectedType"
title="Aucune catégorie sélectionnée"
description="Sélectionnez une catégorie dans l'onglet Général pour voir la structure du squelette."
/>
</div>
</template>
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes) liés à ce composant.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<span
class="badge"
:class="structureSelectionsComplete ? 'badge-success' : structureDataLoading ? 'badge-info' : 'badge-warning'"
>
{{ structureSelectionsComplete ? 'Complet' : structureDataLoading ? 'Chargement…' : 'Incomplet' }}
</span>
</div>
</template>
<div
v-if="structureDataLoading"
class="flex items-center gap-3 rounded-md border border-base-200 bg-base-100 p-3 text-sm text-base-content/70"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
Chargement du catalogue de pièces, produits et composants
</div>
<ComponentStructureAssignmentNode
v-else-if="structureAssignments"
:assignment="structureAssignments"
:pieces="availablePieces"
:products="availableProducts"
:components="availableComponents"
:pieces-loading="piecesLoading"
:products-loading="productsLoading"
:components-loading="componentsLoading"
:piece-type-label-map="pieceTypeLabelMap"
:product-type-label-map="productTypeLabelMap"
:component-type-label-map="componentTypeLabelMap"
/>
<p v-else class="text-xs text-error">
Impossible de générer les emplacements définis par le squelette.
</p>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce composant selon le squelette choisi.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes) liés à ce composant.
</p>
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce composant selon le squelette choisi.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/composants" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -220,11 +248,14 @@
</template>
<script setup lang="ts">
import { watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
const route = useRoute()
const { getConstructeurById } = useConstructeurs()
const activeTab = ref('general')
const {
selectedTypeId,
submitting,
@@ -261,6 +292,13 @@ const {
submitCreation,
} = useComponentCreate()
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'structure', label: 'Structure' },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.constructeurIds,

View File

@@ -54,7 +54,7 @@
@update:constructeur-links="d.constructeurLinks.value = $event"
@remove-constructeur-link="handleRemoveConstructeurLink"
@set-custom-field-value="d.setMachineCustomFieldValue"
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
@custom-fields-saved="() => { if (!isSavingMachine) { d.loadMachineData(); refreshVersions() } }"
/>
<MachineProductsCard
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
@@ -226,6 +226,7 @@ const d = useMachineDetailData(machineId)
const machineInfoCardRef = ref<{ saveFieldDefinitions?: () => Promise<void> } | null>(null)
const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ }
const isSavingMachine = ref(false)
const { confirm: confirmDialog } = useConfirm()
const versionListRef = ref<InstanceType<typeof EntityVersionList> | null>(null)
@@ -315,11 +316,16 @@ const handleFillEntity = (linkId: string, entityKind: string, modelTypeId: strin
}
const submitMachineEdition = async () => {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
isSavingMachine.value = true
try {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
}
await d.submitEdition()
refreshVersions()
} finally {
isSavingMachine.value = false
}
await d.submitEdition()
refreshVersions()
}
const confirmRemoveProduct = async (id: string) => {

View File

@@ -1,245 +0,0 @@
<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 pièces</h1>
<p class="text-sm text-gray-500">
Consultez et gérez toutes les pièces existantes.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/pieces/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter une pièce
</NuxtLink>
<NuxtLink to="/piece-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">Pièces créées</h2>
<p class="text-sm text-base-content/70">
Liste globale des pièces enregistrées, quel que soit leur squelette d'origine.
</p>
</header>
<DataTable
:columns="columns"
:rows="pieceRows"
:loading="loadingPieces"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucune pièce n'a encore été créée."
no-results-message="Aucune pièce 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.piece)"
:alt="resolvePreviewAlt(row.piece)"
/>
</template>
<template #cell-name="{ row }">
{{ row.piece.name || 'Pièce sans nom' }}
</template>
<template #cell-reference="{ row }">
{{ row.piece.reference || '—' }}
</template>
<template #cell-referenceAuto="{ row }">
{{ row.piece.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.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-sm group-hover:pointer-events-auto group-hover:visible">
<p class="break-words whitespace-pre-wrap">{{ row.piece.description }}</p>
</div>
</div>
<span v-else>—</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else>—</span>
</template>
<template #cell-typePiece="{ row }">
<NuxtLink
v-if="row.piece.typePiece?.id"
:to="`/piece-category/${row.piece.typePiece.id}/edit`"
class="link link-hover link-primary"
>
{{ resolvePieceType(row.piece) }}
</NuxtLink>
<span v-else>{{ resolvePieceType(row.piece) }}</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.piece.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/piece/${row.piece.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
:disabled="loadingPieces"
@click="handleDeletePiece(row.piece)"
>
Supprimer
</button>
<NuxtLink
:to="`/piece/${row.piece.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { usePieces } from '~/composables/usePieces'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
import { formatFrenchDate } from '~/utils/date'
const { canEdit } = usePermissions()
const { pieces, total, loadPieces, loading: loadingPieces, deletePiece } = usePieces()
const { pieceTypes, loadPieceTypes } = usePieceTypes()
const table = useDataTable(
{ fetchData: fetchPieces },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typePiece'] },
)
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'createdAt', label: 'Date', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const piecesOnPage = computed(() => pieceRows.value.length)
const paginationState = table.pagination(total, piecesOnPage)
// Enrich pieces with full type data
const piecesList = computed(() => {
return (pieces.value || []).map((piece) => {
const typePiece = pieceTypes.value.find(t => t.id === piece.typePieceId)
return { ...piece, typePiece: typePiece || piece.typePiece || null }
})
})
const pieceRows = computed(() =>
piecesList.value.map(piece => ({
id: piece.id,
piece,
suppliers: buildPieceSuppliersDisplay(piece),
})),
)
async function fetchPieces() {
await loadPieces({
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.typePiece || undefined,
force: true,
})
}
const resolvePieceType = (piece: Record<string, any>) => {
if (piece?.typePiece?.name) return piece.typePiece.name
if (piece?.typePieceLabel) return piece.typePieceLabel
return '—'
}
const buildPieceSuppliersDisplay = (piece: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(piece, 'product'))
const { confirm } = useConfirm()
const handleDeletePiece = async (piece: Record<string, any>) => {
const pieceName = piece?.name || 'cette pièce'
const message = buildDeleteMessage(pieceName, resolveDeleteImpact(piece))
const confirmed = await confirm({ title: 'Supprimer la pièce', message, dangerous: true })
if (!confirmed) return
await deletePiece(piece.id)
fetchPieces()
}
const formatDate = formatFrenchDate
onMounted(async () => {
await Promise.all([fetchPieces(), loadPieceTypes()])
})
</script>

View File

@@ -1,357 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="pieceDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-20 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement de la pièce</p>
</div>
<div v-else-if="!piece" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Pièce introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu retrouver la pièce demandée. Elle a peut-être été supprimée.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier la pièce</h1>
<p class="text-sm text-base-content/70">
Ajustez les informations de la pièce et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<select
v-model="selectedTypeId"
class="select select-bordered select-sm md:select-md"
disabled
>
<option value="">Sélectionner une catégorie</option>
<option
v-for="type in pieceTypeList"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Référence interne ou fournisseur"
>
</div>
<div v-if="piece?.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<input
:value="piece.referenceAuto"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
title="Générée automatiquement à partir du type et des champs personnalisés"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="editionForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
</div>
</div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`edit-requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || saving"
:type-product-id="entry.typeProductId"
helper-text="Un produit valide est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</div>
<StructureSkeletonPreview
v-if="selectedType || resolvedStructure"
:structure="resolvedStructure"
:description="selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(resolvedStructure)"
variant="piece"
/>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à cette pièce.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} prêt{{ selectedFiles.length > 1 ? 's' : '' }} à être ajouté{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
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>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit'
import { useDocuments } from '~/composables/useDocuments'
const route = useRoute()
const { updateDocument } = useDocuments()
const {
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
productSelections,
customFieldInputs,
canEdit,
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition,
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = pieceDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
pieceDocuments.value[idx] = { ...pieceDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
</script>

View File

@@ -1,206 +1,235 @@
<template>
<main class="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouvelle pièce</h1>
<p class="text-sm text-base-content/70">
Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="pieceTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouvelle pièce"
subtitle="Choisissez la catégorie adaptée puis renseignez toutes les informations de votre pièce."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/pieces"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections de la pièce">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de pièce</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="pieceTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-gray-500 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || submitting || !selectedType"
:type-product-id="entry.typeProductId"
helper-text="Un produit est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom de la pièce</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<!-- Description -->
<div class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-model="creationForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Description de la pièce (optionnel)"
rows="3"
/>
</div>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix indicatif ()</span>
</label>
<input
v-model="creationForm.prix"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<!-- Skeleton preview -->
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedType.structure"
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
variant="piece"
/>
</div>
</div>
</div>
</template>
<StructureSkeletonPreview
v-if="selectedType"
:structure="selectedType.structure"
:description="selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.'"
:preview-badge="formatPieceStructurePreview(selectedType.structure)"
variant="piece"
/>
<template #tab-products>
<div class="space-y-6">
<div
v-if="structureProducts.length"
class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4"
>
<header class="space-y-1">
<h2 class="font-semibold text-base-content">
Produit requis par le squelette
</h2>
<p class="text-xs text-base-content/70">
Sélectionnez un produit catalogue compatible avec les exigences ci-dessous.
</p>
</header>
<ul class="space-y-2 text-sm text-base-content/80">
<li
v-for="(description, index) in productRequirementDescriptions"
:key="`requirement-${index}`"
class="flex items-start gap-2"
>
<span class="mt-0.5 inline-flex h-2 w-2 flex-shrink-0 rounded-full bg-primary"></span>
<span>{{ description }}</span>
</li>
</ul>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="entry in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">
{{ entry.label }}
</span>
</label>
<ProductSelect
:model-value="productSelections[entry.index] || null"
:disabled="!canEdit || submitting || !selectedType"
:type-product-id="entry.typeProductId"
helper-text="Un produit est requis pour cette pièce."
@update:model-value="(value) => setProductSelection(entry.index, value)"
/>
</div>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<EmptyState
v-if="!structureProducts.length"
title="Aucun produit requis"
:description="selectedType ? 'Cette catégorie ne requiert pas de produit lié.' : 'Sélectionnez une catégorie pour voir les produits requis.'"
/>
</div>
</template>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes…) liés à cette pièce.
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents (PDF, images, textes…) liés à cette pièce.
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} prêt{{ selectedDocuments.length > 1 ? 's' : '' }} à être ajouté{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours…
</p>
</div>
</template>
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à cette pièce. Ces champs complètent le squelette sélectionné.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/pieces" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -260,6 +289,7 @@ const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const { canEdit } = usePermissions()
const activeTab = ref('general')
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
@@ -382,6 +412,13 @@ const canSubmit = computed(() =>
),
)
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'products', label: 'Produits liés', count: structureProducts.value.length },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
const clearCreationForm = () => {
creationForm.name = ''
creationForm.description = ''
@@ -508,5 +545,4 @@ watch(
onMounted(async () => {
await loadPieceTypes()
})
</script>

View File

@@ -1,257 +0,0 @@
<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 produits</h1>
<p class="text-sm text-base-content/70">
Retrouvez l'ensemble des produits du catalogue, leurs informations fournisseurs et leurs catégories.
</p>
</div>
<div class="flex flex-wrap gap-2">
<NuxtLink to="/product/create" class="btn btn-primary btn-sm md:btn-md">
Ajouter un produit
</NuxtLink>
<NuxtLink to="/product-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">
<div
v-if="errorMessage"
class="alert alert-error"
>
<div class="flex flex-col gap-1">
<span class="font-semibold">Impossible de charger les produits</span>
<span class="text-sm">{{ errorMessage }}</span>
</div>
<button type="button" class="btn btn-ghost btn-sm ml-auto" @click="reload">
Réessayer
</button>
</div>
<DataTable
v-else
:columns="columns"
:rows="productRows"
:loading="loading"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun produit n'a encore été enregistré."
no-results-message="Aucun produit 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.product, true)"
:alt="resolvePreviewAlt(row.product)"
/>
</template>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.product.name }}</span>
</template>
<template #cell-reference="{ row }">
{{ row.product.reference || '—' }}
</template>
<template #cell-typeProduct="{ row }">
<NuxtLink
v-if="row.product.typeProduct?.id"
:to="`/product-category/${row.product.typeProduct.id}/edit`"
class="link link-hover link-primary"
>
{{ row.product.typeProduct.name }}
</NuxtLink>
<span v-else>{{ row.product.typeProduct?.name || '—' }}</span>
</template>
<template #cell-suppliers="{ row }">
<div
v-if="row.suppliers.visible.length"
class="flex max-w-[14rem] flex-wrap items-center gap-1 text-sm"
:title="row.suppliers.tooltip"
>
<span
v-for="supplier in row.suppliers.visible"
:key="supplier"
class="badge badge-ghost badge-sm whitespace-nowrap"
>
{{ supplier }}
</span>
<span
v-if="row.suppliers.overflow"
class="badge badge-outline badge-sm"
>
+{{ row.suppliers.overflow }}
</span>
</div>
<span v-else class="text-sm text-base-content/50">—</span>
</template>
<template #cell-price="{ row }">
{{ formatPrice(row.product.supplierPrice) }}
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="navigateTo(`/product/${row.product.id}?edit=true`)"
>
Modifier
</button>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs text-error"
@click="confirmDelete(row.product)"
>
Supprimer
</button>
<NuxtLink
:to="`/product/${row.product.id}`"
class="btn btn-primary btn-xs"
>
Détails
</NuxtLink>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useHead } from '#imports'
import DataTable from '~/components/common/DataTable.vue'
import { useProducts } from '~/composables/useProducts'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
import { useDataTable } from '~/composables/useDataTable'
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
import { resolveDeleteImpact, buildDeleteMessage } from '~/shared/utils/deleteImpactUtils'
import { resolvePrimaryDocument, resolvePreviewAlt, resolveSupplierNames, buildSuppliersDisplay } from '~/shared/utils/catalogDisplayUtils'
const { canEdit } = usePermissions()
useHead(() => ({ title: 'Catalogue des produits' }))
const {
products,
total,
loading,
error,
loadProducts,
deleteProduct,
} = useProducts()
const { productTypes, loadProductTypes } = useProductTypes()
const toast = useToast()
const table = useDataTable(
{ fetchData: fetchProducts },
{ defaultSort: 'name', defaultDirection: 'asc', defaultPerPage: 20, persistToUrl: true, columnFilterKeys: ['typeProduct'] },
)
const errorMessage = computed(() => (typeof error.value === 'string' && error.value.length ? error.value : null))
const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-16' },
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' },
{ key: 'typeProduct', label: 'Type de produit', filterable: true, filterPlaceholder: 'Filtrer…' },
{ key: 'suppliers', label: 'Fournisseurs' },
{ key: 'price', label: 'Prix indicatif', sortable: true, sortKey: 'supplierPrice', align: 'right' as const },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-32' },
]
const productsOnPage = computed(() => productRows.value.length)
const paginationState = table.pagination(total, productsOnPage)
// Enrich products with full type data
const normalizedProducts = computed(() => {
return (Array.isArray(products.value) ? products.value : []).map((product) => {
const typeProduct = productTypes.value.find(t => t.id === product.typeProductId)
return { ...product, typeProduct: typeProduct || product.typeProduct || null }
})
})
const productRows = computed(() =>
normalizedProducts.value.map(product => ({
id: product.id,
product,
suppliers: buildProductSuppliersDisplay(product),
})),
)
async function fetchProducts() {
await loadProducts({
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.typeProduct || undefined,
force: true,
})
}
const priceFormatter = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
currencyDisplay: 'narrowSymbol',
})
const formatPrice = (value: any) => {
if (value === null || value === undefined || value === '') return '—'
const number = Number(value)
return Number.isNaN(number) ? '—' : priceFormatter.format(number)
}
const buildProductSuppliersDisplay = (product: Record<string, any>) =>
buildSuppliersDisplay(resolveSupplierNames(product))
const reload = () => fetchProducts()
const { confirm } = useConfirm()
const confirmDelete = async (product: Record<string, any>) => {
const productName = product?.name || 'ce produit'
const message = buildDeleteMessage(productName, resolveDeleteImpact(product))
const confirmed = await confirm({ title: 'Supprimer le produit', message, dangerous: true })
if (!confirmed) return
const result = await deleteProduct(product.id)
if (result.success) {
toast.showSuccess(`Produit "${productName}" supprimé`)
}
}
onMounted(async () => {
await Promise.all([fetchProducts(), loadProductTypes()])
})
</script>

View File

@@ -1,563 +0,0 @@
<template>
<div>
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="productDocuments"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<main class="container mx-auto px-6 py-10">
<div v-if="loading" class="flex flex-col items-center gap-4 py-16 text-center">
<span class="loading loading-spinner loading-lg" aria-hidden="true" />
<p class="text-sm text-base-content/70">Chargement du produit</p>
</div>
<div v-else-if="!product" class="max-w-xl mx-auto">
<div class="alert alert-error shadow-lg">
<div>
<h2 class="font-semibold text-lg">Produit introuvable</h2>
<p class="text-sm text-base-content/80">
Nous n'avons pas pu trouver le produit demandé. Il a peut-être été supprimé.
</p>
</div>
</div>
<button type="button" class="btn btn-primary mt-6" @click="$router.back()">
Retour au catalogue
</button>
</div>
<section v-else class="card border border-base-200 bg-base-100 shadow-sm max-w-4xl mx-auto">
<div class="card-body space-y-6">
<header class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-3xl font-semibold text-base-content">Modifier le produit</h1>
<p class="text-sm text-base-content/70">
Mettez à jour les informations du produit et ses champs personnalisés.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<input
:value="product?.typeProduct?.name || 'Catégorie inconnue'"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
>
<p class="text-xs text-base-content/60 mt-1">
La catégorie d'origine ne peut pas être modifiée depuis cette page.
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="editionForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="editionForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
>
</div>
</div>
<div v-if="structurePreview" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Champs définis par la catégorie</h2>
<p class="text-xs text-base-content/70">
{{ productType?.description || 'Le squelette de catégorie contrôle les champs personnalisés disponibles.' }}
</p>
</div>
<span class="badge badge-outline">{{ structurePreview }}</span>
</div>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Gérez les documents associés à ce produit.
</p>
</div>
<span v-if="selectedFiles.length" class="badge badge-outline">
{{ selectedFiles.length }} document{{ selectedFiles.length > 1 ? 's' : '' }} sélectionné{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || saving || uploadingDocuments }">
<DocumentUpload
v-model="selectedFiles"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
@files-added="handleFilesAdded"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
<p v-else-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="canEdit"
:can-edit="true"
:delete-disabled="uploadingDocuments || saving"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="product"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="loadProduct()"
/>
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitEdition">
<span v-if="saving" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<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>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from '#imports'
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useProductHistory } from '~/composables/useProductHistory'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { getModelType } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { canPreviewDocument } from '~/utils/documentPreview'
import { useCustomFieldInputs, type CustomFieldEntityType } from '~/composables/useCustomFieldInputs'
const { canEdit } = usePermissions()
const versionRefreshKey = ref(0)
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const {
loadDocumentsByProduct,
uploadDocuments: uploadProductDocuments,
deleteDocument: deleteProductDocument,
updateDocument,
} = useDocuments()
const { ensureConstructeurs, getConstructeurById } = useConstructeurs()
const { fetchLinks, syncLinks } = useConstructeurLinks()
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const cfDefinitions = ref<any[]>([])
const cfValues = ref<any[]>([])
const entityId = computed(() => product.value?.id ?? null)
const {
fields: customFieldInputs,
requiredFilled: requiredCustomFieldsFilled,
saveAll: saveAllCustomFields,
} = useCustomFieldInputs({
definitions: cfDefinitions,
values: cfValues,
entityType: 'product' as CustomFieldEntityType,
entityId,
})
const loading = ref(true)
const saving = ref(false)
const selectedFiles = ref<File[]>([])
const uploadingDocuments = ref(false)
const loadingDocuments = ref(false)
const productDocuments = ref<any[]>([])
const previewDocument = ref<any | null>(null)
const previewVisible = ref(false)
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const historyFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
supplierPrice: 'Prix fournisseur',
typeProduct: 'Catégorie',
constructeurIds: 'Fournisseurs',
}
const refreshCustomFieldInputs = (
structureOverride?: ProductModelStructure | null,
valuesOverride?: any[] | null,
) => {
const nextStructure = structureOverride ?? structure.value ?? null
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
cfDefinitions.value = nextStructure?.customFields ?? []
cfValues.value = Array.isArray(nextValues) ? nextValues : []
}
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
)
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const openPreview = (doc: any) => {
if (!doc || !canPreviewDocument(doc)) return
previewDocument.value = doc
previewVisible.value = true
}
const closePreview = () => { previewVisible.value = false; previewDocument.value = null }
const openEditModal = (doc: any) => {
editingDocument.value = doc
editModalVisible.value = true
}
const handleDocumentUpdated = async (data: { name?: string; type?: string }) => {
if (!editingDocument.value?.id) return
const result = await updateDocument(editingDocument.value.id, data)
if (result.success) {
const idx = productDocuments.value.findIndex((d: any) => d.id === editingDocument.value?.id)
if (idx !== -1) {
productDocuments.value[idx] = { ...productDocuments.value[idx], ...data }
}
}
editModalVisible.value = false
editingDocument.value = null
}
const loadProduct = async () => {
const id = route.params.id
if (!id || typeof id !== 'string') {
product.value = null
loading.value = false
return
}
const result = await getProduct(id)
if (result.success && result.data) {
product.value = result.data
productDocuments.value = Array.isArray(result.data.documents) ? result.data.documents : []
await loadProductType()
// Use customFieldValues from entity response (enriched with customField definitions via serialization groups)
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
hydrateForm()
// History is non-blocking — template handles its own loading state
loadHistory(result.data.id).catch(() => {})
} else {
product.value = null
}
loading.value = false
}
const refreshDocuments = async () => {
if (!product.value?.id) {
return
}
loadingDocuments.value = true
try {
const result = await loadDocumentsByProduct(product.value.id, { updateStore: false })
if (result.success) {
productDocuments.value = Array.isArray(result.data) ? result.data : []
}
} finally {
loadingDocuments.value = false
}
}
const removeDocument = async (documentId: string | number | null | undefined) => {
if (!documentId) {
return
}
const result = await deleteProductDocument(documentId, { updateStore: false })
if (result.success) {
productDocuments.value = productDocuments.value.filter((doc) => doc.id !== documentId)
toast.showSuccess('Document supprimé')
}
}
const handleFilesAdded = async (files: File[]) => {
if (!files?.length || !product.value?.id) {
return
}
uploadingDocuments.value = true
try {
const result = await uploadProductDocuments(
{
files,
context: { productId: product.value.id },
},
{ updateStore: false },
)
if (result.success) {
selectedFiles.value = []
await refreshDocuments()
toast.showSuccess('Document(s) ajouté(s)')
} else if (result.error) {
toast.showError(result.error)
}
} finally {
uploadingDocuments.value = false
}
}
const loadProductType = async () => {
// Try using the expanded typeProduct from entity response first
const embedded = product.value?.typeProduct
if (embedded && typeof embedded === 'object' && embedded.id) {
const embeddedStructure = embedded.structure ?? null
if (embeddedStructure) {
productType.value = embedded
structure.value = normalizeProductStructureForSave(embeddedStructure)
return
}
}
if (!product.value?.typeProductId) {
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
return
}
try {
const type = await getModelType(product.value.typeProductId)
productType.value = type
structure.value = normalizeProductStructureForSave(type?.structure ?? null)
} catch (error) {
console.error('Erreur lors du chargement du type de produit:', error)
productType.value = embedded ?? null
structure.value = normalizeProductStructureForSave(embedded?.structure ?? null)
}
}
const hydrateForm = () => {
if (!product.value) {
return
}
editionForm.name = product.value.name || ''
editionForm.reference = product.value.reference || ''
// Load constructeur links
fetchLinks('product', String(route.params.id)).then((links) => {
constructeurLinks.value = links
originalConstructeurLinks.value = links.map(l => ({ ...l }))
editionForm.constructeurIds = constructeurIdsFromLinks(links)
if (editionForm.constructeurIds.length) {
void ensureConstructeurs(editionForm.constructeurIds)
}
})
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
? String(product.value.supplierPrice)
: ''
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
}
watch(
() => product.value?.documents,
(docs) => {
if (Array.isArray(docs)) {
productDocuments.value = docs
}
},
{ immediate: true },
)
const submitEdition = async () => {
if (!product.value) {
return
}
const payload: Record<string, any> = {
name: editionForm.name.trim(),
reference: editionForm.reference.trim() || null,
}
const rawPrice = typeof editionForm.supplierPrice === 'string'
? editionForm.supplierPrice.trim()
: editionForm.supplierPrice
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
? Number.isNaN(Number(rawPrice))
? null
: String(Number(rawPrice))
: null
saving.value = true
try {
const result = await updateProduct(product.value.id, payload)
if (result.success && result.data?.id) {
product.value = result.data
const failedFields = await saveAllCustomFields()
if (failedFields.length) {
toast.showError(`Impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
return
}
await syncLinks('product', product.value.id, originalConstructeurLinks.value, constructeurLinks.value)
originalConstructeurLinks.value = constructeurLinks.value.map(l => ({ ...l }))
toast.showSuccess('Produit mis à jour avec succès')
versionRefreshKey.value++
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de mettre à jour le produit')
} finally {
saving.value = false
}
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => editionForm.constructeurIds,
(ids) => {
const currentIds = new Set(constructeurLinks.value.map(l => l.constructeurId))
for (const id of ids) {
if (!currentIds.has(id)) {
const resolved = getConstructeurById(id)
constructeurLinks.value.push({
constructeurId: id,
constructeur: resolved ? { id: resolved.id, name: resolved.name } : null,
supplierReference: null,
})
}
}
constructeurLinks.value = constructeurLinks.value.filter(l => ids.includes(l.constructeurId))
},
)
onMounted(async () => {
await loadProduct()
})
</script>

View File

@@ -359,7 +359,7 @@ import { humanizeError } from '~/shared/utils/errorMessages'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useProductHistory } from '~/composables/useProductHistory'
import { useEntityHistory } from '~/composables/useEntityHistory'
import { usePermissions } from '~/composables/usePermissions'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
@@ -386,7 +386,7 @@ const {
loading: historyLoading,
error: historyError,
loadHistory,
} = useProductHistory()
} = useEntityHistory('product')
const isEditMode = ref(false)
const versionRefreshKey = ref(0)

View File

@@ -1,158 +1,175 @@
<template>
<main class="mx-auto flex w-full max-w-4xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<header class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="space-y-1">
<h1 class="text-3xl font-semibold text-base-content">Nouveau produit</h1>
<p class="text-sm text-base-content/70">
Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue.
</p>
</div>
<button type="button" class="btn btn-ghost btn-sm md:btn-md self-start" @click="$router.back()">
Retour au catalogue
</button>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<main class="container mx-auto px-6 py-10">
<section class="card border border-base-200 bg-base-100 shadow-sm max-w-5xl mx-auto">
<div class="card-body space-y-6">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="productTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
<DetailHeader
title="Nouveau produit"
subtitle="Sélectionnez la catégorie cible puis renseignez toutes les informations de votre produit catalogue."
:is-edit-mode="false"
:can-edit="false"
back-link="/catalogues/produits"
/>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="creationForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<EntityTabs v-model="activeTab" :tabs="entityTabs" aria-label="Sections du produit">
<template #tab-general>
<div class="space-y-6">
<!-- Catégorie -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Catégorie de produit</span>
</label>
<SearchSelect
v-model="selectedTypeId"
:options="productTypeList"
:loading="loadingTypes"
size="sm"
placeholder="Rechercher une catégorie..."
empty-text="Aucune catégorie disponible"
:option-label="typeOptionLabel"
:option-description="typeOptionDescription"
:disabled="!canEdit || loadingTypes || submitting"
/>
<p v-if="loadingTypes" class="text-xs text-base-content/60 mt-1">
Chargement des catégories
</p>
</div>
</div>
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
<!-- Nom -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du produit</span>
</label>
<input
v-model="creationForm.name"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Nom affiché dans le catalogue"
required
>
</div>
</div>
<!-- Référence + Fournisseurs -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-model="creationForm.reference"
type="text"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Référence interne ou fournisseur"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-model="creationForm.constructeurIds"
class="w-full"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Rechercher un ou plusieurs fournisseurs..."
/>
</div>
</div>
<ConstructeurLinksTable
v-if="constructeurLinks.length"
v-model="constructeurLinks"
/>
<!-- Prix -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text">Prix fournisseur indicatif ()</span>
</label>
<input
v-model="creationForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || submitting || !selectedType"
placeholder="Valeur indicatrice"
>
</div>
</div>
<!-- Skeleton preview -->
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
<p class="text-xs text-base-content/70">
{{ selectedType.description || 'Ce squelette définit les champs personnalisés applicables aux produits de cette catégorie.' }}
</p>
</div>
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
</div>
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
Cette catégorie ne définit pas encore de champs personnalisés.
</p>
</div>
</div>
</template>
<template #tab-documents>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
</p>
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
<span class="badge badge-outline">{{ formatProductStructurePreview(selectedType.structure) }}</span>
</div>
</template>
<p v-if="!customFieldInputs.length" class="text-xs text-base-content/70">
Cette catégorie ne définit pas encore de champs personnalisés.
</p>
</div>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<div class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="font-semibold text-base-content">Documents</h2>
<p class="text-xs text-base-content/70">
Ajoutez des documents pour ce produit (fiches techniques, notices, etc.).
</p>
<template #tab-custom-fields>
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
<header class="space-y-1">
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
<p class="text-xs text-base-content/70">
Renseignez les valeurs propres à ce produit catalogue.
</p>
</header>
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || submitting" />
</div>
<span v-if="selectedDocuments.length" class="badge badge-outline">
{{ selectedDocuments.length }} document{{ selectedDocuments.length > 1 ? 's' : '' }} sélectionné{{ selectedDocuments.length > 1 ? 's' : '' }}
</span>
</header>
<div :class="{ 'pointer-events-none opacity-60': !canEdit || submitting || uploadingDocuments }">
<DocumentUpload
v-model="selectedDocuments"
title="Déposer vos fichiers"
subtitle="Formats acceptés : PDF, images, documents…"
<EmptyState
v-else
title="Aucun champ personnalisé"
:description="selectedType ? 'Cette catégorie ne définit pas de champs personnalisés.' : 'Sélectionnez une catégorie pour voir les champs personnalisés.'"
/>
</div>
<p v-if="uploadingDocuments" class="text-xs text-base-content/70">
Téléversement des documents en cours
</p>
</div>
</template>
</EntityTabs>
<!-- Save/Cancel buttons -->
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
<NuxtLink to="/catalogues/produits" class="btn btn-ghost" :class="{ 'btn-disabled': submitting }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
@@ -203,6 +220,7 @@ const { canEdit } = usePermissions()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const activeTab = ref('general')
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
@@ -240,6 +258,12 @@ const selectedType = computed(() => {
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const entityTabs = computed(() => [
{ key: 'general', label: 'Général' },
{ key: 'documents', label: 'Documents', count: selectedDocuments.value.length },
{ key: 'custom-fields', label: 'Champs perso', count: customFieldInputs.value.length },
])
watch(
() => route.query.typeId,
(value) => {
@@ -276,8 +300,6 @@ watch(selectedType, (type) => {
cfDefinitions.value = normalized?.customFields ?? []
})
// requiredCustomFieldsFilled comes from useCustomFieldInputs composable
const canSubmit = computed(() => Boolean(
canEdit.value &&
selectedType.value &&

View File

@@ -5,6 +5,13 @@
* copy of extractCollection (parsing hydra:member / member / data / array).
*/
export function extractTotal(payload: unknown, fallbackLength: number): number {
const p = payload as Record<string, unknown> | null
if (typeof p?.totalItems === 'number') return p.totalItems
if (typeof p?.['hydra:totalItems'] === 'number') return p['hydra:totalItems']
return fallbackLength
}
export function extractCollection<T = any>(payload: unknown): T[] {
if (Array.isArray(payload)) return payload as T[]
const p = payload as Record<string, unknown> | null

View File

@@ -285,12 +285,14 @@ export function fieldKey(field: CustomFieldInput, index: number): string {
/** Whether a field should be persisted (non-empty value) */
export function shouldPersist(field: CustomFieldInput): boolean {
if (field.type === 'boolean') return field.value === 'true' || field.value === 'false'
if (typeof field.value === 'number') return !Number.isNaN(field.value)
return typeof field.value === 'string' && field.value.trim() !== ''
}
/** Format value for save (trim, boolean coercion) */
export function formatValueForSave(field: CustomFieldInput): string {
if (field.type === 'boolean') return field.value === 'true' ? 'true' : 'false'
if (typeof field.value === 'number') return String(field.value)
return typeof field.value === 'string' ? field.value.trim() : ''
}

View File

@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Re-link orphaned CustomField records to their ModelType.
*
* Legacy data created one CustomField definition per composant/piece instead
* of sharing a single definition on the ModelType. This migration:
* 1. Finds orphaned CustomField used by composants → elects one canonical per (ModelType, name)
* 2. Same for pieces
* 3. Re-points CustomFieldValue to the canonical
* 4. Sets typecomposantid / typepieceid on the canonical
* 5. Deletes now-unused duplicate CustomField rows
*/
final class Version20260404_RelinkOrphanedCustomFields extends AbstractMigration
{
public function getDescription(): string
{
return 'Re-link orphaned custom_fields to their model_types via composants and pieces';
}
public function up(Schema $schema): void
{
// ── COMPOSANTS ──────────────────────────────────────────────────
// For each (model_type, field_name) group of orphaned fields used by composants,
// pick the one with the smallest id as canonical, set its typecomposantid,
// re-point all values to it, then delete the duplicates.
$this->addSql(<<<'SQL'
-- Step 1: Create temp table with canonical mapping for COMPOSANT orphans
CREATE TEMP TABLE _cf_composant_canonical AS
SELECT DISTINCT ON (c.typecomposantid, cf.name)
cf.id AS canonical_id,
c.typecomposantid AS mt_id,
cf.name AS cf_name
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN composants c ON c.id = cfv.composantid
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND c.typecomposantid IS NOT NULL
ORDER BY c.typecomposantid, cf.name, cf.id;
SQL);
$this->addSql(<<<'SQL'
-- Step 2: Create temp table listing ALL orphaned composant field ids with their canonical
CREATE TEMP TABLE _cf_composant_all AS
SELECT cf.id AS orphan_id, canon.canonical_id
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN composants c ON c.id = cfv.composantid
INNER JOIN _cf_composant_canonical canon
ON canon.mt_id = c.typecomposantid AND canon.cf_name = cf.name
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND c.typecomposantid IS NOT NULL
AND cf.id != canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 3: Re-point custom_field_values from duplicates to canonical
UPDATE custom_field_values cfv
SET customfieldid = dup.canonical_id
FROM _cf_composant_all dup
WHERE cfv.customfieldid = dup.orphan_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 4: Set typecomposantid on canonical fields
UPDATE custom_fields cf
SET typecomposantid = canon.mt_id
FROM _cf_composant_canonical canon
WHERE cf.id = canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
-- Step 5: Delete duplicate (non-canonical) custom_fields for composants
DELETE FROM custom_fields
WHERE id IN (SELECT orphan_id FROM _cf_composant_all);
SQL);
$this->addSql('DROP TABLE IF EXISTS _cf_composant_all');
$this->addSql('DROP TABLE IF EXISTS _cf_composant_canonical');
// ── PIECES ───────────<E29480><E29480><EFBFBD>────────────────────────────<E29480><E29480><EFBFBD>─────────────
$this->addSql(<<<'SQL'
CREATE TEMP TABLE _cf_piece_canonical AS
SELECT DISTINCT ON (p.typepieceid, cf.name)
cf.id AS canonical_id,
p.typepieceid AS mt_id,
cf.name AS cf_name
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN pieces p ON p.id = cfv.pieceid
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND p.typepieceid IS NOT NULL
ORDER BY p.typepieceid, cf.name, cf.id;
SQL);
$this->addSql(<<<'SQL'
CREATE TEMP TABLE _cf_piece_all AS
SELECT cf.id AS orphan_id, canon.canonical_id
FROM custom_fields cf
INNER JOIN custom_field_values cfv ON cfv.customfieldid = cf.id
INNER JOIN pieces p ON p.id = cfv.pieceid
INNER JOIN _cf_piece_canonical canon
ON canon.mt_id = p.typepieceid AND canon.cf_name = cf.name
WHERE cf.typecomposantid IS NULL
AND cf.typepieceid IS NULL
AND cf.typeproductid IS NULL
AND cf.machineid IS NULL
AND p.typepieceid IS NOT NULL
AND cf.id != canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
UPDATE custom_field_values cfv
SET customfieldid = dup.canonical_id
FROM _cf_piece_all dup
WHERE cfv.customfieldid = dup.orphan_id;
SQL);
$this->addSql(<<<'SQL'
UPDATE custom_fields cf
SET typepieceid = canon.mt_id
FROM _cf_piece_canonical canon
WHERE cf.id = canon.canonical_id;
SQL);
$this->addSql(<<<'SQL'
DELETE FROM custom_fields
WHERE id IN (SELECT orphan_id FROM _cf_piece_all);
SQL);
$this->addSql('DROP TABLE IF EXISTS _cf_piece_all');
$this->addSql('DROP TABLE IF EXISTS _cf_piece_canonical');
}
public function down(Schema $schema): void
{
// Data migration — not reversible. The old per-entity duplicates are gone.
$this->addSql('SELECT 1');
}
}