refactor : merge Inventory_frontend submodule into frontend/ directory

Merges the full git history of Inventory_frontend into the monorepo
under frontend/. Removes the submodule in favor of a unified repo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-04-01 14:17:57 +02:00
226 changed files with 56920 additions and 4 deletions

View File

@@ -0,0 +1,234 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header>
<h1 class="text-3xl font-semibold text-base-content">Journal d'activité</h1>
<p class="text-sm text-gray-500">
Historique des modifications sur l'ensemble des pièces, produits et composants.
</p>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<DataTable
:columns="columns"
:rows="entries"
:loading="loading"
:pagination="paginationState"
:show-per-page="true"
:show-counter="true"
:expandable="true"
:expanded-keys="expandedIds"
:can-expand="canExpandRow"
row-key="id"
empty-message="Aucune activité enregistrée."
no-results-message="Aucune activité ne correspond à vos filtres."
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@toggle-expand="toggleExpanded"
>
<template #toolbar>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-entity-type"
>
Type
</label>
<select
id="activity-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="">Tous</option>
<option value="piece">Pièce</option>
<option value="product">Produit</option>
<option value="composant">Composant</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="activity-action"
>
Action
</label>
<select
id="activity-action"
v-model="actionFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="">Toutes</option>
<option value="create">Création</option>
<option value="update">Modification</option>
<option value="delete">Suppression</option>
</select>
</div>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatHistoryDate(row.createdAt) }}</span>
</template>
<template #cell-action="{ row }">
<span
class="badge badge-sm"
:class="actionBadgeClass(row.action)"
>
{{ historyActionLabel(row.action) }}
</span>
</template>
<template #cell-entityType="{ row }">
<span class="badge badge-ghost badge-sm">
{{ entityTypeLabel(row.entityType) }}
</span>
</template>
<template #cell-entity="{ row }">
<NuxtLink
v-if="row.action !== 'delete'"
:to="entityEditLink(row)"
class="link link-hover link-primary"
>
{{ row.entityName || 'Sans nom' }}
</NuxtLink>
<span v-else class="text-base-content/50 line-through">
{{ row.entityName || 'Sans nom' }}
</span>
<span
v-if="row.entityRef"
class="text-xs text-base-content/50 ml-1"
>
({{ row.entityRef }})
</span>
</template>
<template #cell-author="{ row }">
{{ row.actor?.label || '—' }}
</template>
<template #row-expanded="{ row }">
<div class="space-y-1 text-sm">
<div
v-for="diffEntry in historyDiffEntries(row, globalFieldLabels)"
:key="diffEntry.field"
class="flex gap-2"
>
<span class="font-medium min-w-[10rem]">{{ diffEntry.label }} :</span>
<span class="text-error line-through">{{ diffEntry.fromLabel }}</span>
<span></span>
<span class="text-success">{{ diffEntry.toLabel }}</span>
</div>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useActivityLog } from '~/composables/useActivityLog'
import type { ActivityLogEntry } from '~/composables/useActivityLog'
import { useDataTable } from '~/composables/useDataTable'
import {
historyActionLabel,
formatHistoryDate,
historyDiffEntries,
} from '~/shared/utils/historyDisplayUtils'
const { entries, total, loading, loadActivityLog } = useActivityLog()
const table = useDataTable(
{ fetchData: fetchLog },
{
defaultSort: 'createdAt',
defaultDirection: 'desc',
defaultPerPage: 50,
persistToUrl: true,
extraParams: {
entityType: { default: '' },
action: { default: '' },
},
},
)
const entityTypeFilter = table.filters.entityType as Ref<string>
const actionFilter = table.filters.action as Ref<string>
const entriesOnPage = computed(() => entries.value.length)
const paginationState = table.pagination(total, entriesOnPage)
const columns = [
{ key: 'createdAt', label: 'Date' },
{ key: 'action', label: 'Action' },
{ key: 'entityType', label: 'Type' },
{ key: 'entity', label: 'Entité' },
{ key: 'author', label: 'Auteur' },
]
const expandedIds = reactive(new Set<string>())
const toggleExpanded = (id: string) => {
if (expandedIds.has(id)) expandedIds.delete(id)
else expandedIds.add(id)
}
const canExpandRow = (row: any) =>
row.diff !== null && row.diff !== undefined && Object.keys(row.diff).length > 0
function fetchLog() {
loadActivityLog({
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
entityType: entityTypeFilter.value || undefined,
action: actionFilter.value || undefined,
})
}
const ENTITY_TYPE_LABELS: Record<string, string> = {
piece: 'Pièce',
product: 'Produit',
composant: 'Composant',
}
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
const ENTITY_EDIT_ROUTES: Record<string, string> = {
piece: '/pieces',
product: '/product',
composant: '/component',
}
const entityEditLink = (entry: ActivityLogEntry) => {
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? ''
return base ? `${base}/${entry.entityId}/edit` : '#'
}
const actionBadgeClass = (action: string) => {
if (action === 'create') return 'badge-success'
if (action === 'delete') return 'badge-error'
return 'badge-warning'
}
const globalFieldLabels: Record<string, string> = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
supplierPrice: 'Prix fournisseur',
typePiece: 'Type de pièce',
typeProduct: 'Type de produit',
typeComposant: 'Type de composant',
product: 'Produit',
productIds: 'Produits',
constructeurIds: 'Fournisseurs',
structure: 'Structure',
}
onMounted(fetchLog)
</script>

View File

@@ -0,0 +1,270 @@
<template>
<div class="container mx-auto p-6 max-w-6xl">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">
Administration des profils
</h1>
<button class="btn btn-primary btn-sm" @click="showCreateDialog = true">
Nouveau profil
</button>
</div>
<DataTable
:columns="columns"
:rows="sortedProfiles"
:loading="isLoading"
:sort="sortState"
:show-counter="false"
table-class="table-zebra"
empty-message="Aucun profil."
@sort="handleSort"
>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.firstName }} {{ row.lastName }}</span>
</template>
<template #cell-email="{ row }">
<span class="text-sm text-base-content/70">{{ row.email || '-' }}</span>
</template>
<template #cell-role="{ row }">
<select
class="select select-bordered select-xs"
:value="primaryRole(row)"
@change="handleRoleChange(row.id, $event.target.value)"
>
<option value="ROLE_ADMIN">Admin</option>
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
<option value="ROLE_VIEWER">Viewer</option>
</select>
</template>
<template #cell-password="{ row }">
<span v-if="row.hasPassword" class="badge badge-success badge-sm">Oui</span>
<span v-else class="badge badge-ghost badge-sm">Non</span>
<button
class="btn btn-ghost btn-xs ml-1"
@click="openPasswordDialog(row.id)"
>
{{ row.hasPassword ? 'Changer' : 'Definir' }}
</button>
</template>
<template #cell-status="{ row }">
<span
class="badge badge-sm"
:class="row.isActive ? 'badge-success' : 'badge-error'"
>
{{ row.isActive ? 'Actif' : 'Inactif' }}
</span>
</template>
<template #cell-actions="{ row }">
<button
v-if="row.isActive"
class="btn btn-ghost btn-xs text-error"
@click="handleDeactivate(row.id)"
>
Desactiver
</button>
</template>
</DataTable>
<!-- Create Profile Dialog -->
<dialog ref="createDialog" class="modal" :open="showCreateDialog || undefined">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Nouveau profil
</h3>
<form @submit.prevent="handleCreate">
<div class="form-control mb-3">
<label class="label"><span class="label-text">Prenom</span></label>
<input v-model="createForm.firstName" type="text" class="input input-bordered" required>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="createForm.lastName" type="text" class="input input-bordered" required>
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Email</span></label>
<input v-model="createForm.email" type="email" class="input input-bordered">
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Mot de passe</span></label>
<input v-model="createForm.password" type="password" class="input input-bordered">
</div>
<div class="form-control mb-3">
<label class="label"><span class="label-text">Role</span></label>
<select v-model="createForm.role" class="select select-bordered">
<option value="ROLE_ADMIN">Admin</option>
<option value="ROLE_GESTIONNAIRE">Gestionnaire</option>
<option value="ROLE_VIEWER">Viewer</option>
</select>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="showCreateDialog = false">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="creating">
<span v-if="creating" class="loading loading-spinner loading-xs" />
Creer
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" @click="showCreateDialog = false">
close
</button>
</form>
</dialog>
<!-- Set Password Dialog -->
<dialog ref="passwordDialog" class="modal" :open="showPasswordDialog || undefined">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
Definir le mot de passe
</h3>
<form @submit.prevent="handleSetPassword">
<div class="form-control mb-3">
<label class="label"><span class="label-text">Nouveau mot de passe</span></label>
<input v-model="newPassword" type="password" class="input input-bordered" required>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click="showPasswordDialog = false">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="settingPassword">
<span v-if="settingPassword" class="loading loading-spinner loading-xs" />
Valider
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" @click="showPasswordDialog = false">
close
</button>
</form>
</dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useAdminProfiles } from '#imports'
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
const loaded = ref(false)
const isLoading = computed(() => loading.value || !loaded.value)
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'role', label: 'Role', sortable: true },
{ key: 'password', label: 'Mot de passe' },
{ key: 'status', label: 'Statut', sortable: true },
{ key: 'actions', label: 'Actions' },
]
const sortState = ref({ field: 'name', direction: 'asc' })
const handleSort = (sort) => {
sortState.value = sort
}
const sortedProfiles = computed(() => {
const { field, direction } = sortState.value
const dir = direction === 'desc' ? -1 : 1
return [...profiles.value].sort((a, b) => {
let valA, valB
if (field === 'name') {
valA = `${a.firstName} ${a.lastName}`.toLowerCase()
valB = `${b.firstName} ${b.lastName}`.toLowerCase()
}
else if (field === 'role') {
valA = primaryRole(a)
valB = primaryRole(b)
}
else if (field === 'status') {
valA = a.isActive ? '1' : '0'
valB = b.isActive ? '1' : '0'
}
else {
valA = (a[field] || '').toLowerCase()
valB = (b[field] || '').toLowerCase()
}
return dir * valA.localeCompare(valB)
})
})
const showCreateDialog = ref(false)
const showPasswordDialog = ref(false)
const creating = ref(false)
const settingPassword = ref(false)
const passwordProfileId = ref(null)
const newPassword = ref('')
const createForm = ref({
firstName: '',
lastName: '',
email: '',
password: '',
role: 'ROLE_VIEWER',
})
const primaryRole = (profile) => {
const roles = profile.roles || []
if (roles.includes('ROLE_ADMIN')) { return 'ROLE_ADMIN' }
if (roles.includes('ROLE_GESTIONNAIRE')) { return 'ROLE_GESTIONNAIRE' }
return 'ROLE_VIEWER'
}
const handleCreate = async () => {
creating.value = true
try {
const data = { ...createForm.value }
if (!data.email) { delete data.email }
if (!data.password) { delete data.password }
await createProfile(data)
showCreateDialog.value = false
createForm.value = { firstName: '', lastName: '', email: '', password: '', role: 'ROLE_VIEWER' }
}
finally {
creating.value = false
}
}
const handleRoleChange = async (profileId, role) => {
await updateRole(profileId, role)
}
const openPasswordDialog = (profileId) => {
passwordProfileId.value = profileId
newPassword.value = ''
showPasswordDialog.value = true
}
const handleSetPassword = async () => {
if (!passwordProfileId.value) { return }
settingPassword.value = true
try {
await setPassword(passwordProfileId.value, newPassword.value)
showPasswordDialog.value = false
}
finally {
settingPassword.value = false
}
}
const handleDeactivate = async (profileId) => {
await deactivateProfile(profileId)
}
onMounted(async () => {
await fetchAll()
loaded.value = true
})
</script>

View File

@@ -0,0 +1,694 @@
<template>
<main class="container mx-auto max-w-4xl px-6 py-10 space-y-8">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-base-content">Changelog</h1>
<p class="text-sm text-base-content/70">
Historique des modifications et nouvelles fonctionnalités de
l'application.
</p>
</header>
<section
v-for="release in releases"
:key="release.version"
class="card border border-base-200 bg-base-100 shadow-sm"
>
<div class="card-body space-y-3">
<div class="flex items-center gap-3">
<h2 class="text-xl font-bold text-base-content">
{{ release.version }}
</h2>
<span class="badge badge-ghost text-xs">{{
release.date
}}</span>
</div>
<ul class="space-y-2">
<li
v-for="(item, i) in release.changes"
:key="i"
class="flex items-start gap-2 text-sm text-base-content/80"
>
<span
class="badge badge-sm mt-0.5 shrink-0"
:class="badgeClass(item.type)"
>
{{ item.type }}
</span>
<span>{{ item.text }}</span>
</li>
</ul>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { useHead } from "#imports";
useHead({ title: "Changelog" });
type ChangeType = "feat" | "fix" | "perf" | "chore";
interface Change {
type: ChangeType;
text: string;
}
interface Release {
version: string;
date: string;
changes: Change[];
}
const badgeClass = (type: ChangeType) => {
const map: Record<ChangeType, string> = {
feat: "badge-primary",
fix: "badge-error",
perf: "badge-warning",
chore: "badge-ghost",
};
return map[type] ?? "badge-ghost";
};
const releases: Release[] = [
{
version: "v1.9.5",
date: "2026-03-31",
changes: [
{
type: "feat",
text: "Référence automatique des pièces et composants : génération d'une référence technique à partir d'une formule configurable sur la catégorie, recalculée automatiquement à chaque modification des champs personnalisés",
},
{
type: "feat",
text: "Formula builder interactif : sélection des champs disponibles par clic (chips) avec insertion à la position du curseur, aperçu live avec valeurs d'exemple, et calcul automatique des champs requis",
},
{
type: "feat",
text: "Versioning des entités : numéro de version incrémenté automatiquement à chaque modification, avec historique des versions et possibilité de restaurer une version antérieure",
},
{
type: "feat",
text: "Bouton de sauvegarde unique sur la fiche machine : remplacement des sauvegardes automatiques par un bouton explicite, avec affichage des versions sur les liens composants/pièces/produits",
},
],
},
{
version: "v1.9.4",
date: "2026-03-25",
changes: [
{
type: "feat",
text: "Pages de consultation détaillées pour les pièces, composants et produits : vue lecture seule avec affichage propre des informations, fournisseurs, champs personnalisés et documents",
},
{
type: "feat",
text: "Bouton bascule Modifier / Voir détails sur les fiches pièces, composants et produits, identique au fonctionnement de la fiche machine",
},
{
type: "feat",
text: "Bouton « Détails » sur les catalogues pièces, composants et produits pour accéder directement à la vue consultation",
},
{
type: "feat",
text: "Masquage automatique des champs vides et de la section documents en mode consultation",
},
{
type: "feat",
text: "Accès direct au mode édition via le paramètre ?edit=true dans l'URL",
},
],
},
{
version: "v1.9.2",
date: "2026-03-23",
changes: [
{
type: "feat",
text: "Serveur MCP (Model Context Protocol) : l'application expose désormais un serveur MCP permettant l'intégration avec des assistants IA — outils CRUD complets pour toutes les entités, recherche inventaire, historique, commentaires, champs personnalisés, documents, slots et structure machine",
},
{
type: "feat",
text: "Types de documents : classification des documents par type (Plan, Photo, Fiche technique, Notice, Certificat, Facture, Bon de commande, Autre) avec filtre dédié sur la page documents, sélection du type à l'upload et possibilité de modifier le type après upload",
},
{
type: "feat",
text: "Filtre sites multi-sélection sur le Parc Machines : remplacement du menu déroulant par des cases à cocher permettant de filtrer sur un ou plusieurs sites simultanément",
},
{
type: "feat",
text: "Tri alphabétique automatique des machines sur le Parc Machines",
},
{
type: "feat",
text: "Recherche par nom OU référence sur les catalogues : la recherche dans les catalogues pièces, composants et produits cherche désormais dans le nom et la référence simultanément (extension Doctrine OR search)",
},
{
type: "feat",
text: "Quantité sur les slots pièces : ajout d'un champ quantité éditable directement depuis la page d'édition d'un composant",
},
{
type: "feat",
text: "Lien rapide vers la catégorie depuis la page d'édition d'un composant",
},
{
type: "feat",
text: "Redirection vers la page d'édition après création d'un composant, d'une pièce ou d'un produit",
},
{
type: "fix",
text: "Correction de la suppression de fournisseurs sur les pièces, composants et produits : la suppression est maintenant persistée correctement",
},
{
type: "fix",
text: "Correction de la création de composants : les sélections de pièces, produits et sous-composants sont maintenant sauvegardées, et les slots squelette sont correctement initialisés",
},
{
type: "fix",
text: "Correction de la perte de données lors de la sauvegarde d'une catégorie (champs personnalisés et structure)",
},
{
type: "fix",
text: "Correction de la suppression de composants depuis la fiche machine (utilisation du linkId au lieu du composantId)",
},
{
type: "fix",
text: "Amélioration de l'envoi des fournisseurs en PATCH : le tableau est toujours envoyé pour éviter les pertes",
},
{
type: "fix",
text: "Filtrage serveur des options dans les sélecteurs de slots au lieu du filtrage client",
},
{
type: "fix",
text: "Page d'édition pièce : rester sur la page après sauvegarde au lieu de rediriger",
},
{
type: "fix",
text: "Messages d'erreur 409 (conflit) : extraction du champ d'erreur pour un message compréhensible",
},
{
type: "perf",
text: "Suppression des chargements catalogue redondants sur la page d'édition composant",
},
],
},
{
version: "v1.9.1",
date: "2026-03-16",
changes: [
{
type: "feat",
text: "Normalisation JSON → tables relationnelles : les structures des composants (pièces, produits, sous-composants) et les squelettes des catégories sont désormais stockés dans des tables dédiées au lieu de colonnes JSON, améliorant la fiabilité et les performances des requêtes",
},
{
type: "feat",
text: "Synchronisation des catégories (ModelType Sync) : la modification d'une catégorie (ajout/suppression de slots ou champs personnalisés) peut être propagée automatiquement à tous les éléments existants de cette catégorie, avec prévisualisation des changements avant application",
},
{
type: "feat",
text: "Sélection interactive des items dans les slots : sur la page d'édition d'un composant, il est maintenant possible de choisir directement la pièce, le produit ou le sous-composant assigné à chaque emplacement du squelette via des sélecteurs avec recherche",
},
{
type: "feat",
text: "Endpoints PATCH pour les slots composant : modification de la quantité et de l'item sélectionné sur les slots pièce, produit et sous-composant",
},
{
type: "feat",
text: "Table de relation pièce ↔ produit (PieceProductSlot) avec versioning pour le suivi des modifications de structure",
},
{
type: "feat",
text: "Gestion des champs personnalisés sur les catégories : synchronisation automatique des définitions de champs (ajout, modification, suppression) lors de la sauvegarde d'une catégorie",
},
{
type: "feat",
text: "Suite de tests étendue : 219 tests couvrant les stratégies de synchronisation, le contrôleur de sync et les nouvelles entités",
},
{
type: "fix",
text: "Correction de l'affichage des sélections pré-existantes dans les slots : les pièces, produits et sous-composants déjà assignés sont maintenant correctement affichés à l'ouverture de la page d'édition (correction du cache catalogue)",
},
{
type: "fix",
text: "Fallback position/orderIndex sur index de tableau dans les stratégies de sync pour éviter les erreurs quand le champ est absent",
},
],
},
{
version: "v1.9.0",
date: "2026-03-09",
changes: [
{
type: "feat",
text: "Gestion des champs personnalisés sur les machines : ajout, modification et suppression de définitions de champs directement depuis la fiche machine",
},
{
type: "feat",
text: "Refonte UI globale : amélioration du styling, des layouts et du responsive sur l'ensemble des composants et pages",
},
{
type: "feat",
text: "Suite de tests API complète : 167 tests couvrant toutes les entités, la sécurité et les validations",
},
{
type: "feat",
text: "Endpoint /api/health pour le monitoring applicatif",
},
{
type: "fix",
text: "Sécurité renforcée : désactivation de la migration de session sur le firewall API, durcissement des accès documents et sessions",
},
{
type: "fix",
text: "Confirmation de suppression avec impact sur le catalogue produits (documents, liaisons machines en cascade)",
},
{
type: "fix",
text: "Correction du débordement des dropdowns dans les DataTable",
},
{
type: "perf",
text: "Refactoring massif du frontend : extraction de 15+ composables et composants partagés, réduction de la taille des fichiers",
},
{
type: "chore",
text: "Extraction de CuidEntityTrait et abstraction du subscriber d'audit côté backend",
},
{
type: "chore",
text: "Ajout de DAMA DoctrineTestBundle pour l'isolation des tests par transaction",
},
],
},
{
version: "v1.8.1",
date: "2026-03-05",
changes: [
{
type: "feat",
text: "Composant DataTable générique avec tri, recherche, pagination et filtres server-side toutes les pages catalogue migrées vers ce composant partagé",
},
{
type: "feat",
text: "Messages d'erreur humanisés : les erreurs backend sont traduites en messages compréhensibles pour l'utilisateur final",
},
{
type: "feat",
text: "Modal d'ajout d'entités aux machines : ajout direct de composants, pièces et produits depuis la fiche machine",
},
{
type: "feat",
text: "Filtres SearchFilter ipartial sur les noms de types de modèles et commentaires côté API",
},
{
type: "feat",
text: "Suppression du système TypeMachine (squelettes machines) : les champs personnalisés sont désormais liés directement à chaque machine",
},
{
type: "feat",
text: "Simplification de la création de machines : plus besoin de sélectionner un squelette, ajout direct des entités",
},
{
type: "fix",
text: "Suppression catalogue pièces/composants : confirmation avec liste des éléments supprimés en cascade (documents, liaisons machine, champs personnalisés) au lieu de bloquer la suppression",
},
{
type: "fix",
text: "Affichage des catégories sur les pages d'édition (produit, composant, pièce) : correction de « Catégorie inconnue » causée par un import obsolète dans ModelType",
},
{
type: "fix",
text: "Recherche insensible à la casse sur les commentaires et documents (partial → ipartial)",
},
{
type: "chore",
text: "Suppression des pages squelettes machines (/machine-skeleton, /type) et composants associés",
},
],
},
{
version: "v1.8.0",
date: "2026-03-03",
changes: [
{
type: "feat",
text: "Stockage des documents sur le système de fichiers au lieu de Base64 en base de données, avec endpoints dédiés pour servir et télécharger les fichiers",
},
{
type: "feat",
text: "Pagination serveur sur la page Documents avec recherche, tri (date/nom/taille), filtre par rattachement et sélecteur d'éléments par page",
},
{
type: "feat",
text: "Compression PDF automatique à l'upload via Ghostscript, avec commande pour compresser les PDFs existants",
},
{
type: "feat",
text: "Champ description sur les pièces et composants, visible dans les catalogues avec popover au survol",
},
{
type: "feat",
text: "Commande de migration app:migrate-documents-to-filesystem pour migrer les documents existants (Base64 → fichiers)",
},
{
type: "fix",
text: "Normalisation des documents : fileUrl et downloadUrl toujours exposés dans l'API",
},
{
type: "fix",
text: "Édition de squelettes machines : correction du conflit UniqueEntity et de l'interférence du désérialiseur",
},
{
type: "fix",
text: "Sites : ajout de l'opération PATCH et correction de la migration de contrainte",
},
{
type: "chore",
text: "Réorganisation de la navbar avec nouvelles icônes Lucide",
},
],
},
{
version: "v1.7.0",
date: "2026-03-02",
changes: [
{
type: "feat",
text: "Système de commentaires / tickets : possibilité de laisser des commentaires sur les fiches (machines, pièces, composants, produits, catégories, squelettes) avec résolution par les gestionnaires",
},
{
type: "feat",
text: "Page commentaires centralisée (/comments) avec filtres par statut, type d'entité, pagination et liens cliquables vers les fiches",
},
{
type: "feat",
text: "Badge notifications : compteur de commentaires ouverts sur l'avatar utilisateur et dans le menu profil (polling 60s)",
},
{
type: "feat",
text: "Contrôle d'accès par rôles : ROLE_ADMIN, ROLE_GESTIONNAIRE, ROLE_VIEWER avec permissions granulaires sur toutes les pages",
},
{
type: "feat",
text: "Journal d'audit étendu : suivi des opérations sur machines, fournisseurs, types de modèles, documents et conversions",
},
{
type: "feat",
text: "Commande app:init-profile-passwords pour l'initialisation en masse des mots de passe et rôles",
},
{
type: "fix",
text: "Toggle switch pour les champs personnalisés booléens (remplace les checkboxes)",
},
{
type: "fix",
text: "Recherche fournisseur : filtrage côté client au lieu d'appels API debounce",
},
{
type: "fix",
text: "Prévention des doublons de noms de fournisseurs et de références de pièces (contraintes unique)",
},
{
type: "fix",
text: "Correction de la création de squelettes machines : pagination, duplication, champs personnalisés",
},
],
},
{
version: "v1.6.1",
date: "2026-02-12",
changes: [
{
type: "feat",
text: "Suivi d'audit étendu : enregistrement des opérations CRUD sur les machines, fournisseurs, catégories (ModelType) et documents",
},
{
type: "feat",
text: "Traçabilité des conversions de catégories dans le journal d'activité (action « convert » avec direction, nombre et noms des éléments)",
},
{
type: "feat",
text: "Endpoint historique machine : GET /api/machines/{id}/history",
},
],
},
{
version: "v1.6.0",
date: "2026-02-12",
changes: [
{
type: "feat",
text: "Conversion bidirectionnelle des catégories : possibilité de convertir une catégorie de pièce en catégorie de composant (et inversement) avec transfert automatique de tous les éléments, documents, champs personnalisés et fournisseurs",
},
{
type: "feat",
text: "Vérification des conditions de blocage avant conversion : liaisons machines, templates de type machine, sous-composants dans la structure, collisions de noms",
},
{
type: "feat",
text: "Bouton « Convertir » sur les listes de catégories pièce et composant avec modale de confirmation détaillée",
},
{
type: "chore",
text: "Passage php-cs-fixer sur l'ensemble des contrôleurs et entités du backend",
},
],
},
{
version: "v1.5.0",
date: "2026-02-11",
changes: [
{
type: "feat",
text: "Page de journal d'activité globale avec filtres par entité, par acteur et pagination serveur",
},
{
type: "feat",
text: "Suivi d'audit : enregistrement des noms de fournisseurs et des modifications de champs personnalisés",
},
{
type: "feat",
text: "Préservation de l'état des listes dans l'URL (page courante, recherche, tri, direction, filtres) — le retour navigateur restaure exactement la position précédente",
},
{
type: "feat",
text: "Boutons « Retour » sur toutes les pages de création et d'édition utilisent désormais l'historique du navigateur au lieu de liens fixes",
},
{
type: "feat",
text: "Première lettre automatiquement en majuscule lors de la création de catégories et de composants",
},
{
type: "feat",
text: "Les types de catégories dans les tableaux des catalogues sont maintenant cliquables (lien vers la fiche d'édition)",
},
{
type: "feat",
text: "Application des couleurs de marque Malio sur l'ensemble du thème (navbar, boutons, badges)",
},
{
type: "feat",
text: "Page changelog accessible depuis le footer",
},
{
type: "fix",
text: "Correction des filtres de tri et de recherche cassés sur les catalogues composants, pièces et produits",
},
{
type: "fix",
text: "Correction du filtre par rattachement (site, machine, composant, pièce) sur la page documents",
},
{
type: "fix",
text: "Correction de l'affichage des champs personnalisés sur les pages d'édition (condition de concurrence)",
},
{
type: "fix",
text: "Plafonnement de la pagination à 200 éléments par page pour éviter les erreurs mémoire en production",
},
{
type: "perf",
text: "Cache intelligent sur les composables usePieces et useComposants : les données déjà chargées ne sont plus re-téléchargées inutilement",
},
{
type: "perf",
text: "Réduction des appels API bloquants sur les pages d'édition",
},
],
},
{
version: "v1.4.0",
date: "2026-02-04",
changes: [
{
type: "perf",
text: "Optimisation de la sérialisation API : ajout de groupes dédiés pour CustomFieldValue et CustomField, réduisant significativement la taille des réponses",
},
{
type: "perf",
text: "Pages d'édition machines/composants/pièces : chargement parallèle des données au lieu de séquentiel",
},
],
},
{
version: "v1.3.0",
date: "2026-01-28",
changes: [
{
type: "feat",
text: "Refactoring complet du frontend : découpage des méga-composants en modules réutilisables (7 chantiers F1-F7)",
},
{
type: "feat",
text: "Page détail machine découpée de 2989 à 219 lignes avec 2 composables et 7 sous-composants",
},
{
type: "feat",
text: "Page création machine découpée de 1231 à 196 lignes avec 1 composable et 5 sous-composants",
},
{
type: "feat",
text: "Extraction de 4 modules utilitaires partagés (champs personnalisés, affichage produits, documents, fournisseurs)",
},
{
type: "feat",
text: "Fusion des composables dupliqués : 3 composables d'historique et 3 composables de types fusionnés en versions génériques",
},
{
type: "feat",
text: "Remplacement de confirm() natif par une modale DaisyUI personnalisée sur l'ensemble de l'application",
},
{
type: "feat",
text: "Extraction de la navbar dans un composant AppNavbar dédié",
},
{
type: "feat",
text: "Suite de 54 tests unitaires avec Vitest couvrant les utilitaires et composables",
},
{
type: "perf",
text: "Optimisations API : helper extractCollection partagé, invalidation de cache ciblée",
},
{
type: "chore",
text: "Migration des composables JavaScript vers TypeScript strict",
},
{
type: "chore",
text: "Activation de règles ESLint strictes et suppression de 19 console.log de débogage",
},
],
},
{
version: "v1.2.0",
date: "2026-01-21",
changes: [
{
type: "feat",
text: "Système de suivi d'historique (audit) avec enregistrement automatique des modifications sur toutes les entités",
},
{
type: "feat",
text: "Interface dédiée à l'historique sur les fiches produits, pièces et composants",
},
{
type: "feat",
text: "Modale d'éléments liés sur les pages de gestion des catégories avec navigation directe vers la fiche d'édition",
},
{
type: "feat",
text: "Possibilité d'ajouter des champs personnalisés en mode restreint sur les catégories",
},
],
},
{
version: "v1.1.1",
date: "2026-01-14",
changes: [
{
type: "feat",
text: "Compression automatique des fichiers PDF à l'upload via qpdf, réduisant l'espace de stockage",
},
{
type: "chore",
text: "Ajout de qpdf dans l'image Docker pour le support de la compression PDF",
},
],
},
{
version: "v1.1.0",
date: "2026-01-07",
changes: [
{
type: "fix",
text: "Recherche insensible à la casse sur l'ensemble des filtres de toutes les entités (machines, composants, pièces, produits)",
},
{
type: "chore",
text: "Réinitialisation des migrations vers un schéma initial unique avec guide de déploiement",
},
{
type: "chore",
text: "Mise à jour des fixtures avec les données courantes de la base",
},
],
},
{
version: "v1.0.0",
date: "2025-12-15",
changes: [
{
type: "feat",
text: "Gestion complète des machines : création, édition, vue détaillée avec liaisons composants et pièces",
},
{
type: "feat",
text: "Catalogues composants, pièces et produits avec recherche serveur, tri et pagination",
},
{
type: "feat",
text: "Système de catégories (types) avec squelettes de champs personnalisés et drag & drop pour réordonner",
},
{
type: "feat",
text: "Upload de documents avec prévisualisation PDF et images, miniatures dans les tableaux",
},
{
type: "feat",
text: "Gestion des fournisseurs multiples avec résolution automatique des noms",
},
{
type: "feat",
text: "Exigences produit sur les pièces : support de liaisons multiples",
},
{
type: "feat",
text: "Sélections de composants sur les pièces avec recherche dynamique",
},
{
type: "feat",
text: "Système de sessions utilisateurs avec authentification par cookie",
},
{
type: "feat",
text: "Mémorisation des préférences de tri par catalogue (cookies)",
},
{
type: "feat",
text: "Formatage automatique des contacts et des montants en format français",
},
{
type: "feat",
text: "Protection contre les suppressions : affichage des dépendances bloquantes avant confirmation",
},
{
type: "chore",
text: "Infrastructure Docker complète avec PostgreSQL, PHP 8.4, API Platform et pgAdmin",
},
],
},
];
</script>

View File

@@ -0,0 +1,264 @@
<template>
<main class="container mx-auto px-6 py-10 space-y-8">
<header>
<h1 class="text-3xl font-semibold text-base-content">
Commentaires
</h1>
<p class="text-sm text-base-content/50">
Liste de tous les commentaires et tickets ouverts sur les fiches.
</p>
</header>
<section class="card border border-base-200 bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<DataTable
:columns="columns"
:rows="comments"
:loading="loadingList"
:sort="table.sort.value"
:pagination="paginationState"
:column-filters="table.columnFilters.value"
:show-per-page="true"
empty-message="Aucun commentaire trouvé."
no-results-message="Aucun commentaire trouvé."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
@update:column-filters="table.handleColumnFiltersChange"
>
<template #toolbar>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="comment-status"
>
Statut
</label>
<select
id="comment-status"
v-model="statusFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="open">Ouverts</option>
<option value="resolved">Résolus</option>
<option value="">Tous</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="comment-entity-type"
>
Type
</label>
<select
id="comment-entity-type"
v-model="entityTypeFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="">Tous</option>
<option value="machine">Machine</option>
<option value="piece">Pièce</option>
<option value="composant">Composant</option>
<option value="product">Produit</option>
<option value="piece_category">Catégorie pièce</option>
<option value="component_category">Catégorie composant</option>
<option value="product_category">Catégorie produit</option>
<option value="machine_skeleton">Squelette machine</option>
</select>
</div>
</template>
<template #cell-content="{ row }">
<div class="tooltip tooltip-top max-w-xs" :data-tip="row.content">
<span class="line-clamp-2 text-sm text-left">{{ row.content }}</span>
</div>
<CommentDocumentList :documents="getDocuments(row)" />
</template>
<template #cell-entityType="{ row }">
<span class="badge badge-outline badge-sm">
{{ entityTypeLabel(row.entityType) }}
</span>
</template>
<template #cell-entity="{ row }">
<NuxtLink
v-if="getEntityRoute(row)"
:to="getEntityRoute(row)!"
class="link link-primary text-sm font-medium"
>
{{ row.entityName || row.entityId }}
</NuxtLink>
<span v-else class="text-sm">
{{ row.entityName || row.entityId }}
</span>
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatCommentDate(row.createdAt) }}</span>
</template>
<template #cell-status="{ row }">
<span
class="badge badge-sm"
:class="row.status === 'open' ? 'badge-warning' : 'badge-success'"
>
{{ row.status === 'open' ? 'Ouvert' : 'Résolu' }}
</span>
</template>
<template v-if="canEdit" #cell-actions="{ row }">
<button
v-if="row.status === 'open'"
type="button"
class="btn btn-success btn-xs gap-1"
:disabled="loading"
@click="handleResolve(row.id)"
>
<IconLucideCheck class="w-3 h-3" />
Résoudre
</button>
<span v-else class="text-xs text-base-content/50">
{{ row.resolvedByName }}
</span>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
import CommentDocumentList from '~/components/CommentDocumentList.vue'
import { usePermissions } from '~/composables/usePermissions'
import { useDataTable } from '~/composables/useDataTable'
import IconLucideCheck from '~icons/lucide/check'
const { canEdit } = usePermissions()
const {
loading,
fetchAllComments,
resolveComment,
} = useComments()
const comments = ref<Comment[]>([])
const total = ref(0)
const loadingList = ref(true)
const getDocuments = (comment: Comment): CommentDocument[] =>
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
const table = useDataTable(
{ fetchData: loadComments },
{
defaultSort: 'createdAt',
defaultDirection: 'desc',
defaultPerPage: 20,
persistToUrl: true,
extraParams: {
status: { default: 'open' },
entityType: { default: '' },
},
columnFilterKeys: ['entity'],
},
)
const statusFilter = table.filters.status as Ref<string>
const entityTypeFilter = table.filters.entityType as Ref<string>
const commentsOnPage = computed(() => comments.value.length)
const paginationState = table.pagination(total, commentsOnPage)
const columns = computed(() => {
const cols = [
{ key: 'content', label: 'Contenu', class: 'max-w-xs' },
{ key: 'entityType', label: 'Type' },
{ key: 'entity', label: 'Item', filterable: true, filterPlaceholder: 'Rechercher…' },
{ key: 'authorName', label: 'Auteur', sortable: true, sortKey: 'authorName' },
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
{ key: 'status', label: 'Statut', sortable: true, sortKey: 'status' },
]
if (canEdit.value) {
cols.push({ key: 'actions', label: 'Actions' })
}
return cols
})
const ENTITY_TYPE_LABELS: Record<string, string> = {
machine: 'Machine',
piece: 'Pièce',
composant: 'Composant',
product: 'Produit',
piece_category: 'Cat. pièce',
component_category: 'Cat. composant',
product_category: 'Cat. produit',
machine_skeleton: 'Squelette',
}
const entityTypeLabel = (type: string): string =>
ENTITY_TYPE_LABELS[type] ?? type
const formatCommentDate = (dateStr: string): string => {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return '—'
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date)
}
async function loadComments() {
loadingList.value = true
const result = await fetchAllComments({
status: statusFilter.value || undefined,
entityType: entityTypeFilter.value || undefined,
entityName: table.columnFilters.value.entity || undefined,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value,
})
if (result.success) {
comments.value = result.data ?? []
total.value = result.total ?? 0
}
loadingList.value = false
}
const handleResolve = async (commentId: string) => {
const result = await resolveComment(commentId)
if (result.success) {
await loadComments()
}
}
const ENTITY_ROUTE_MAP: Record<string, (id: string) => string> = {
machine: (id: string) => `/machine/${id}`,
piece: (id: string) => `/piece/${id}`,
composant: (id: string) => `/component/${id}`,
product: (id: string) => `/product/${id}`,
piece_category: (id: string) => `/piece-category/${id}/edit`,
component_category: (id: string) => `/component-category/${id}/edit`,
product_category: (id: string) => `/product-category/${id}/edit`,
machine_skeleton: (id: string) => `/type/${id}`,
}
const getEntityRoute = (comment: Comment): string | null => {
const builder = ENTITY_ROUTE_MAP[comment.entityType]
return builder ? builder(comment.entityId) : null
}
onMounted(() => {
loadComments()
})
</script>

View File

@@ -0,0 +1,212 @@
<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

@@ -0,0 +1,206 @@
<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="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">{{ title }}</h1>
<p class="text-base text-base-content/70">
Ajustez le squelette et les métadonnées de cette catégorie de composant. Les modifications seront appliquées lors des prochaines créations de composants.
</p>
</div>
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</button>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<div v-if="loading" class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement de la catégorie</span>
</div>
<ModelTypeForm
v-else
mode="edit"
initial-category="COMPONENT"
:initial-data="initialData"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="component_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { ComponentModelStructure } from '~/shared/types/inventory'
import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadComponentTypes } = useComponentTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name
? `Modifier « ${initialData.value.name} »`
: 'Modifier une catégorie de composant',
)
useHead(() => ({
title: title.value,
}))
const navigateBackToList = async () => {
await router.push('/component-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const normalizeError = (error: any) => {
const message = error?.data?.message || error?.message || 'Une erreur est survenue.'
return Array.isArray(message) ? message[0] : message
}
const loadCategory = async () => {
loading.value = true
try {
const id = String(route.params.id)
const response = await getModelType(id)
if (response.category !== 'COMPONENT') {
showError("Cette catégorie n'est pas un composant." )
await navigateBackToList()
return
}
initialData.value = {
name: response.name,
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
structure: (response.structure as ComponentModelStructure | null) ?? undefined,
referenceFormula: response.referenceFormula ?? null,
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
}
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
} finally {
loading.value = false
}
}
const handleCancel = () => {
navigateBackToList()
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
const id = String(route.params.id)
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload?.notes ?? null,
}
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
}
} catch (error) {
showError(normalizeError(error))
} finally {
saving.value = false
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadComponentTypes({ force: true })
showSuccess('Catégorie de composant mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})
</script>

View File

@@ -0,0 +1,10 @@
<template>
<ManagementView
category="COMPONENT"
heading="Catégories de composant"
/>
</template>
<script setup lang="ts">
import ManagementView from '~/components/model-types/ManagementView.vue'
</script>

View File

@@ -0,0 +1,74 @@
<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="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">Nouvelle catégorie de composant</h1>
<p class="text-base text-base-content/70">
Configurez le squelette canonique qui sera appliqué lors de la création des composants appartenant à cette catégorie.
</p>
</div>
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</button>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<ModelTypeForm
mode="create"
initial-category="COMPONENT"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({
title: 'Nouvelle catégorie de composant',
}))
const router = useRouter()
const { showError, showSuccess } = useToast()
const saving = ref(false)
const handleCancel = () => {
router.push('/component-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('COMPONENT')
showSuccess('Catégorie de composant créée avec succès.')
await router.push('/component-category')
} catch (error: any) {
const message = error?.data?.message || error?.message || 'Une erreur est survenue lors de la création.'
showError(Array.isArray(message) ? message[0] : message)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,452 @@
<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">{{ 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">{{ 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">{{ 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

@@ -0,0 +1,558 @@
<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">
<DetailHeader
:title="isEditMode ? 'Modifier le composant' : component.name"
:subtitle="isEditMode ? 'Ajustez les informations du composant et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/component-catalog"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<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>
<template v-if="isEditMode">
<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>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ selectedType?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<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-if="isEditMode"
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 v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.name }}
</div>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || component.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
v-model="editionForm.description"
class="textarea textarea-bordered textarea-sm md:textarea-md"
:disabled="!canEdit || saving"
placeholder="Description du composant (optionnel)"
rows="3"
/>
<div v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ component.description }}
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || component.reference || constructeurLinks.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || component.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
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 v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.reference }}
</div>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="component?.constructeurs || []"
/>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || component.prix" 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-if="isEditMode"
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 v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ component.prix }}
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && 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"
/>
<!-- Skeleton slot selections -->
<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">{{ isEditMode ? 'Sélections du squelette' : 'Structure du composant' }}</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Choisissez les pièces, produits et sous-composants pour chaque emplacement requis par la catégorie.' : 'Pièces, produits et sous-composants associés à ce composant.' }}
</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">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<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>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
{{ slot.selectedPieceName || '— Non sélectionné' }}
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
</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">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ProductSelect
:model-value="slot.selectedProductId"
:disabled="!canEdit || saving"
:type-product-id="slot.typeProductId"
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ slot.selectedProductName || '— Non sélectionné' }}
</div>
</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">{{ slot.label }}</span>
</label>
<template v-if="isEditMode">
<ComposantSelect
:model-value="slot.selectedComponentId"
:disabled="!canEdit || saving"
:type-composant-id="slot.typeComposantId"
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
/>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ slot.selectedComponentName || '— Non sélectionné' }}
</div>
</div>
</div>
</div>
</div>
<!-- Custom fields -->
<div v-if="visibleCustomFields.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 v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce composant.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || componentDocuments.length > 0"
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">
{{ isEditMode ? 'Gérez les documents associés à ce composant.' : 'Documents associés à ce composant.' }}
</p>
</div>
<span v-if="isEditMode && 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>
<template v-if="isEditMode">
<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"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="componentDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce composant pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
</button>
<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="composant"
:entity-id="String(route.params.id)"
:entity-name="component?.name"
show-resolved
/>
</div>
</div>
</section>
</main>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from '#imports'
import { useComponentEdit } from '~/composables/useComponentEdit'
import { useDocuments } from '~/composables/useDocuments'
import { usePermissions } from '~/composables/usePermissions'
import { useConstructeurs } from '~/composables/useConstructeurs'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
const route = useRoute()
const { canEdit } = usePermissions()
const { getConstructeurById } = useConstructeurs()
const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const {
component,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
componentDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
constructeurIdsFromForm,
customFieldInputs,
historyFieldLabels,
canSubmit,
componentTypeList,
selectedType,
selectedTypeStructure,
pieceSlotEntries,
productSlotEntries,
subcomponentSlotEntries,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
submitEdition: _submitEdition,
fetchComponent,
setSlotQuantity,
setPieceSlotSelection,
setProductSlotSelection,
setSubcomponentSlotSelection,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
formatStructurePreview,
} = useComponentEdit(String(route.params.id))
const submitEdition = async () => {
await _submitEdition()
if (!saving.value) {
await fetchComponent()
isEditMode.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,
})
}
}
// 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 visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(
(f) => f.value !== null && f.value !== undefined && f.value !== '',
)
})
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
}
onMounted(() => {
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>

View File

@@ -0,0 +1,282 @@
<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">
<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"
/>
<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>
<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"
/>
<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>
<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>
</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>
<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 }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
<span v-if="submitting" class="loading loading-spinner loading-sm mr-2"></span>
Créer le composant
</button>
</div>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { watch } from 'vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
const { getConstructeurById } = useConstructeurs()
const {
selectedTypeId,
submitting,
creationForm,
constructeurLinks,
customFieldInputs,
structureAssignments,
selectedDocuments,
uploadingDocuments,
loadingTypes,
componentTypeList,
selectedType,
selectedTypeStructure,
availablePieces,
availableProducts,
availableComponents,
piecesLoading,
productsLoading,
componentsLoading,
structureDataLoading,
pieceTypeLabelMap,
productTypeLabelMap,
componentTypeLabelMap,
structureHasRequirements,
structureSelectionsComplete,
canEdit,
canSubmit,
typeOptionLabel,
typeOptionDescription,
formatStructurePreview,
resolvePieceLabel,
resolveProductLabel,
resolveSubcomponentLabel,
submitCreation,
} = useComponentCreate()
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.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))
},
)
</script>

View File

@@ -0,0 +1,240 @@
<template>
<main class="container mx-auto px-6 py-8 space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">
Fournisseurs
</h1>
<p class="text-sm text-gray-500">
Gérez les fournisseurs et leurs coordonnées.
</p>
</div>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Nouveau fournisseur
</button>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-4">
<DataTable
:columns="columns"
:rows="filteredConstructeurs"
:loading="loading"
:sort="currentSort"
:show-counter="false"
empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé."
@sort="handleSort"
>
<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="searchTerm"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom, email ou téléphone"
@input="debouncedSearch"
/>
</label>
</template>
<template #cell-phone="{ row }">
{{ formatPhoneDisplay(row.phone) }}
</template>
<template #cell-createdAt="{ row }">
<span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-ghost btn-xs" @click="openEditModal(row)">
{{ canEdit ? 'Modifier' : 'Consulter' }}
</button>
<button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
Supprimer
</button>
</div>
</template>
</DataTable>
</div>
</div>
<dialog class="modal" :class="{ 'modal-open': modalOpen }">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
</h3>
<form class="space-y-4" @submit.prevent="saveConstructeur">
<div class="form-control">
<label class="label"><span class="label-text">Nom</span></label>
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
</div>
<div class="modal-action">
<button type="button" class="btn" @click="closeModal">
Annuler
</button>
<button type="submit" class="btn btn-primary" :disabled="!canEdit || saving">
<span v-if="saving" class="loading loading-spinner loading-xs mr-2" />
{{ editingConstructeur ? 'Enregistrer' : 'Créer' }}
</button>
</div>
</form>
</div>
</dialog>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs()
const { showError } = useToast()
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const searchTerm = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc')
const currentSort = computed(() => ({
field: sortKey.value,
direction: sortDir.value,
}))
const handleSort = (sort) => {
sortKey.value = sort.field
sortDir.value = sort.direction
}
const modalOpen = ref(false)
const saving = ref(false)
const editingConstructeur = ref(null)
const form = ref({ name: '', email: '', phone: '' })
const filteredConstructeurs = computed(() => {
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
const sorted = [...constructeurs.value].sort((a, b) => {
if (key === 'createdAt') {
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
}
return dir * (a[key] || '').localeCompare(b[key] || '')
})
if (!searchTerm.value) { return sorted }
const term = searchTerm.value.toLowerCase()
return sorted.filter(item =>
[item.name, item.email, item.phone].some(value => value && value.toLowerCase().includes(term)),
)
})
const debouncedSearch = debounce(async () => {
await searchConstructeurs(searchTerm.value)
}, 300)
const formatDate = formatFrenchDate
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
function debounce(fn, delay) {
let timeout
return (...args) => {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), delay)
}
}
const resetForm = () => {
form.value = { name: '', email: '', phone: '' }
editingConstructeur.value = null
}
const openCreateModal = () => {
resetForm()
modalOpen.value = true
}
const openEditModal = (constructeur) => {
editingConstructeur.value = constructeur
form.value = {
name: constructeur.name,
email: constructeur.email || '',
phone: constructeur.phone || '',
}
modalOpen.value = true
}
const closeModal = () => {
modalOpen.value = false
resetForm()
}
const saveConstructeur = async () => {
const trimmedName = form.value.name.trim()
const duplicate = constructeurs.value.find(
c => c.name.toLowerCase() === trimmedName.toLowerCase()
&& c.id !== editingConstructeur.value?.id,
)
if (duplicate) {
showError(`Un fournisseur "${duplicate.name}" existe déjà.`)
return
}
saving.value = true
const payload = { ...form.value, name: trimmedName }
if (!payload.email) { delete payload.email }
if (!payload.phone) { delete payload.phone }
let result
if (editingConstructeur.value) {
result = await updateConstructeur(editingConstructeur.value.id, payload)
}
else {
result = await createConstructeur(payload)
}
saving.value = false
if (result.success) {
closeModal()
await searchConstructeurs(searchTerm.value)
}
}
const { confirm } = useConfirm()
const confirmDelete = async (constructeur) => {
if (!await confirm({ message: `Supprimer le fournisseur "${constructeur.name}" ?` })) { return }
const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) {
showError(result.error)
}
}
onMounted(() => loadConstructeurs())
</script>

View File

@@ -0,0 +1,271 @@
<template>
<main class="container mx-auto px-6 py-8 space-y-8">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="documents"
@close="closePreview"
/>
<DocumentEditModal
:visible="editModalVisible"
:document="editingDocument"
@close="editModalVisible = false"
@updated="handleDocumentUpdated"
/>
<section class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6">
<DataTable
:columns="columns"
:rows="documents"
:loading="loading"
:sort="table.sort.value"
:pagination="paginationState"
:show-per-page="true"
empty-message="Aucun document n'a encore été ajouté."
no-results-message="Aucun document ne correspond à votre recherche."
@sort="table.handleSort"
@update:current-page="table.handlePageChange"
@update:per-page="table.handlePerPageChange"
>
<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 du document..."
@input="table.debouncedSearch"
/>
</label>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-filter"
>
Rattachement
</label>
<select
id="doc-filter"
v-model="attachmentFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="all">Tous</option>
<option value="site">Sites</option>
<option value="machine">Machines</option>
<option value="composant">Composants</option>
<option value="piece">Pi&egrave;ces</option>
<option value="product">Produits</option>
</select>
</div>
<div class="flex items-center gap-2">
<label
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
for="doc-type-filter"
>
Type
</label>
<select
id="doc-type-filter"
v-model="typeFilter"
class="select select-bordered select-sm"
@change="table.handleFilterChange"
>
<option value="all">Tous</option>
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
{{ t.label }}
</option>
</select>
</div>
</template>
<template #cell-name="{ row }">
<div class="flex items-center gap-3">
<span class="text-xl" :class="documentIcon(row).colorClass">
<component
:is="documentIcon(row).component"
class="h-6 w-6"
aria-hidden="true"
/>
</span>
<div>
<div class="font-semibold">{{ row.name }}</div>
<div class="text-xs text-base-content/50">{{ row.filename }}</div>
</div>
</div>
</template>
<template #cell-mimeType="{ row }">
{{ row.mimeType || 'Inconnu' }}
</template>
<template #cell-type="{ row }">
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(row.type || 'documentation') }}</span>
</template>
<template #cell-size="{ row }">
{{ formatSize(row.size) }}
</template>
<template #cell-attachment="{ row }">
<div class="flex flex-col text-xs">
<span v-if="row.site">Site &middot; {{ row.site.name }}</span>
<span v-else-if="row.machine">Machine &middot; {{ row.machine.name }}</span>
<span v-else-if="row.composant">Composant &middot; {{ row.composant.name }}</span>
<span v-else-if="row.piece">Pi&egrave;ce &middot; {{ row.piece.name }}</span>
<span v-else-if="row.product">Produit &middot; {{ row.product.name }}</span>
<span v-else class="text-base-content/30">Non d&eacute;fini</span>
</div>
</template>
<template #cell-createdAt="{ row }">
{{ formatFrenchDate(row.createdAt) }}
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end gap-2">
<button
v-if="canEdit"
class="btn btn-ghost btn-xs"
type="button"
@click="openEditModal(row)"
>
Modifier
</button>
<button
class="btn btn-ghost btn-xs"
type="button"
:disabled="!canPreviewDocument(row)"
:title="canPreviewDocument(row) ? 'Consulter le document' : 'Aucun aper\u00E7u disponible pour ce type'"
@click="openPreview(row)"
>
Consulter
</button>
<button class="btn btn-ghost btn-xs" type="button" @click="downloadDocument(row)">
T&eacute;l&eacute;charger
</button>
</div>
</template>
</DataTable>
</div>
</section>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, type Ref } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useDocuments } from '~/composables/useDocuments'
import { useDataTable } from '~/composables/useDataTable'
import { usePermissions } from '~/composables/usePermissions'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatFrenchDate } from '~/utils/date'
import { DOCUMENT_TYPES, getDocumentTypeLabel } from '~/shared/documentTypes'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
const { documents, total, loading, loadDocuments, updateDocument } = useDocuments()
const { canEdit } = usePermissions()
const table = useDataTable(
{ fetchData: fetchDocuments },
{
defaultSort: 'createdAt',
defaultDirection: 'desc',
defaultPerPage: 30,
persistToUrl: true,
extraParams: {
filter: { default: 'all' },
typeFilter: { default: 'all' },
},
},
)
const attachmentFilter = table.filters.filter as Ref<string>
const typeFilter = table.filters.typeFilter as Ref<string>
const previewDocument = ref<any>(null)
const previewVisible = ref(false)
const editingDocument = ref<any>(null)
const editModalVisible = ref(false)
const documentsOnPage = computed(() => documents.value.length)
const paginationState = table.pagination(total, documentsOnPage)
const columns = [
{ key: 'name', label: 'Nom', sortable: true, sortKey: 'name' },
{ key: 'mimeType', label: 'Type MIME' },
{ key: 'type', label: 'Type' },
{ key: 'size', label: 'Taille', sortable: true, sortKey: 'size' },
{ key: 'attachment', label: 'Rattaché à' },
{ key: 'createdAt', label: 'Date', sortable: true, sortKey: 'createdAt' },
{ key: 'actions', label: 'Actions', align: 'right' as const },
]
async function fetchDocuments() {
await loadDocuments({
search: table.searchTerm.value,
page: table.currentPage.value,
itemsPerPage: table.itemsPerPage.value,
orderBy: table.sortField.value,
orderDir: table.sortDirection.value as 'asc' | 'desc',
attachmentFilter: attachmentFilter.value,
type: typeFilter.value,
force: true,
})
}
const formatSize = (size: number | undefined | null) => {
if (size === undefined || size === null) return '\u2014'
if (size === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
const formatted = size / Math.pow(1024, index)
return `${formatted.toFixed(1)} ${units[index]}`
}
const documentIcon = (doc: any) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
const downloadDocument = (doc: any) => {
if (doc?.downloadUrl) window.open(doc.downloadUrl, '_blank')
}
const openPreview = (doc: any) => {
if (!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 doc = documents.value.find((d) => d.id === editingDocument.value.id)
if (doc) {
doc.name = data.name
doc.type = data.type
}
}
editModalVisible.value = false
editingDocument.value = null
}
onMounted(() => {
fetchDocuments()
})
</script>

View File

@@ -0,0 +1,439 @@
<template>
<main class="container mx-auto px-6 py-8">
<div class="my-8">
<!-- Header with Stats -->
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-between mb-8">
<div>
<h2 class="text-3xl font-bold text-base-content tracking-tight">
Vue d'ensemble
</h2>
<p class="text-base-content/50 mt-1">
Machines organisées par site
</p>
</div>
<div class="flex gap-3">
<div class="bg-base-100 rounded-xl border border-base-300/50 px-5 py-3 shadow-sm">
<p class="text-[0.65rem] font-semibold uppercase tracking-widest text-base-content/40 mb-0.5">Sites</p>
<p class="text-2xl font-bold text-primary tracking-tight">{{ sites.length }}</p>
</div>
<div class="bg-base-100 rounded-xl border border-base-300/50 px-5 py-3 shadow-sm">
<p class="text-[0.65rem] font-semibold uppercase tracking-widest text-base-content/40 mb-0.5">Machines</p>
<p class="text-2xl font-bold text-secondary tracking-tight">{{ totalMachines }}</p>
</div>
</div>
</div>
<!-- Filters -->
<div class="card bg-base-100 shadow-sm mb-8">
<div class="card-body py-4">
<div class="flex flex-col md:flex-row md:items-end gap-4">
<div class="form-control flex-1">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Rechercher</span>
</label>
<div class="relative">
<IconLucideSearch class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/30" aria-hidden="true" />
<input
v-model="searchTerm"
type="text"
placeholder="Nom de machine ou site..."
class="input input-bordered pl-10 w-full"
>
</div>
</div>
<div class="form-control md:w-64">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
</label>
<select v-model="selectedSiteFilter" class="select select-bordered w-full">
<option value="">
Tous les sites
</option>
<option
v-for="site in sites"
:key="site.id"
:value="site.id"
>
{{ site.name }}
</option>
</select>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg text-primary" />
</div>
<!-- Empty State -->
<div v-else-if="filteredSites.length === 0" class="text-center py-16">
<div class="max-w-sm mx-auto">
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<IconLucideFactory
class="w-8 h-8 text-base-content/30"
aria-hidden="true"
/>
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">
Aucune machine trouvée
</h3>
<p class="text-sm text-base-content/50 mb-6">
Commencez par ajouter des sites et des machines.
</p>
<div class="flex gap-2 justify-center">
<button v-if="canEdit" class="btn btn-primary btn-sm" @click="showAddSiteModal = true">
Ajouter un site
</button>
<button
v-if="canEdit"
class="btn btn-ghost btn-sm"
@click="showAddMachineModal = true"
>
Ajouter une machine
</button>
</div>
</div>
</div>
<!-- Sites List -->
<div v-else class="space-y-5">
<div
v-for="site in filteredSites"
:key="site.id"
class="card site-card shadow-md overflow-hidden"
:style="{
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
>
<div class="card-body">
<!-- Site Header -->
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4">
<div
class="w-11 h-11 rounded-xl grid place-items-center shrink-0"
:style="{ backgroundColor: site.color ? site.color + '40' : 'oklch(var(--p) / 0.1)', border: site.color ? `2px solid ${site.color}60` : 'none' }"
>
<IconLucideMapPin class="w-5 h-5" :style="{ color: site.color || 'oklch(var(--p))' }" aria-hidden="true" />
</div>
<div class="min-w-0">
<h3
class="text-lg font-bold tracking-tight text-base-content"
>
{{ site.name }}
</h3>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-1.5 text-sm text-base-content/50">
<span v-if="site.contactName" class="flex items-center gap-1.5">
<IconLucideUser class="w-3.5 h-3.5" aria-hidden="true" />
{{ site.contactName }}
</span>
<span v-if="site.contactPhone" class="flex items-center gap-1.5">
<IconLucidePhone class="w-3.5 h-3.5" aria-hidden="true" />
{{ formatPhoneDisplay(site.contactPhone) }}
</span>
<span v-if="site.contactCity" class="flex items-center gap-1.5">
<IconLucideMapPinned class="w-3.5 h-3.5" aria-hidden="true" />
{{ site.contactPostalCode }} {{ site.contactCity }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="badge font-bold"
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
:class="!site.color ? 'badge-primary' : ''"
>
{{ site.machines?.length || 0 }}
</span>
<button
class="btn btn-ghost btn-xs btn-circle"
@click="toggleSiteCollapse(site.id)"
>
<IconLucideChevronDown
class="w-4 h-4 transition-transform duration-200"
:class="collapsedSites.includes(site.id) ? 'rotate-180' : ''"
aria-hidden="true"
/>
</button>
</div>
</div>
<!-- Machines Grid -->
<div
v-if="
!collapsedSites.includes(site.id) &&
site.machines &&
site.machines.length > 0
"
class="mt-4 pt-4 border-t border-base-200/80"
>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div
v-for="machine in site.machines"
:key="machine.id"
class="group flex flex-col rounded-xl border border-base-300/40 bg-base-100 shadow-sm hover:shadow-md hover:border-primary/20 transition-all cursor-pointer p-4"
@click="viewMachineDetails(machine)"
>
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-sm text-base-content group-hover:text-primary transition-colors truncate">
{{ machine.name }}
</h4>
</div>
<div v-if="machine.reference" class="flex items-center gap-1.5 text-xs text-base-content/40">
<IconLucideTag class="w-3 h-3" aria-hidden="true" />
<span>{{ machine.reference }}</span>
</div>
<div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button
v-if="canEdit"
class="btn btn-ghost btn-sm"
@click.stop="editMachine(machine)"
>
Modifier
</button>
<button
v-if="canEdit"
class="btn btn-ghost btn-sm text-error"
@click.stop="confirmDeleteMachine(machine)"
>
Supprimer
</button>
<NuxtLink
:to="`/machine/${machine.id}`"
class="btn btn-primary btn-sm"
@click.stop
>
Détails
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Empty Site State -->
<div
v-else-if="
!collapsedSites.includes(site.id) &&
(!site.machines || site.machines.length === 0)
"
class="text-center py-8 mt-4 border-t border-base-200/80"
>
<div class="w-10 h-10 rounded-xl bg-base-200 grid place-items-center mx-auto mb-3">
<IconLucideFactory class="w-5 h-5 text-base-content/25" aria-hidden="true" />
</div>
<p class="text-sm text-base-content/40 mb-3">
Aucune machine dans ce site
</p>
<button
v-if="canEdit"
class="btn btn-sm btn-primary btn-outline"
@click="addMachineToSite(site)"
>
Ajouter une machine
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Add Site Modal -->
<AddSiteModal
:open="showAddSiteModal"
:disabled="!canEdit"
@close="showAddSiteModal = false"
@create="handleCreateSite"
/>
<!-- Add Machine Modal -->
<AddMachineModal
:open="showAddMachineModal"
:sites="sites"
:disabled="!canEdit"
:preselected-site-id="preselectedSiteId"
@close="showAddMachineModal = false"
@create="handleCreateMachine"
/>
</main>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useSites } from '~/composables/useSites'
import { useMachines } from '~/composables/useMachines'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideUser from '~icons/lucide/user'
import IconLucidePhone from '~icons/lucide/phone'
import IconLucideMapPinned from '~icons/lucide/map-pinned'
import IconLucideChevronDown from '~icons/lucide/chevron-down'
import IconLucideTag from '~icons/lucide/tag'
import IconLucideSearch from '~icons/lucide/search'
import { formatPhone } from '~/utils/formatters/phone'
import { extractRelationId } from '~/shared/apiRelations'
const { canEdit } = usePermissions()
const { sites, loading, loadSites, createSite } = useSites()
const { machines, loadMachines, createMachine, deleteMachine } = useMachines()
// Data
const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false)
const searchTerm = ref('')
const selectedSiteFilter = ref('')
const collapsedSites = ref([])
const preselectedSiteId = ref('')
// Computed
const machinesBySiteId = computed(() => {
const map = new Map()
machines.value.forEach((machine) => {
const siteId = machine.siteId || extractRelationId(machine.site)
if (!siteId) { return }
if (!map.has(siteId)) {
map.set(siteId, [])
}
map.get(siteId).push(machine)
})
return map
})
const sitesWithMachines = computed(() => {
return sites.value.map((site) => ({
...site,
machines: machinesBySiteId.value.get(site.id) || []
}))
})
const totalMachines = computed(() => {
return sitesWithMachines.value.reduce((total, site) => {
return total + (site.machines?.length || 0)
}, 0)
})
const formatPhoneDisplay = (value) => {
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
const filteredSites = computed(() => {
let filtered = sitesWithMachines.value
// Filtrer par site
if (selectedSiteFilter.value) {
filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
}
// Filtrer par terme de recherche
if (searchTerm.value) {
filtered = filtered.filter((site) => {
const lowerTerm = searchTerm.value.toLowerCase()
const siteMatches = [
site.name,
site.contactName,
site.contactPhone,
site.contactAddress,
site.contactPostalCode,
site.contactCity
].some((field) => {
if (!field) { return false }
return field.toLowerCase().includes(lowerTerm)
})
const machineMatches = site.machines?.some(
machine => {
const name = (machine.name || '').toLowerCase()
const reference = (machine.reference || '').toLowerCase()
return name.includes(lowerTerm) || reference.includes(lowerTerm)
}
)
return siteMatches || machineMatches
})
}
return filtered
})
// Methods
const handleCreateSite = async (data) => {
const result = await createSite(data)
if (result.success) {
showAddSiteModal.value = false
}
}
const handleCreateMachine = async (data) => {
const result = await createMachine(data)
if (result.success) {
showAddMachineModal.value = false
await loadMachines()
}
}
const toggleSiteCollapse = (siteId) => {
const index = collapsedSites.value.indexOf(siteId)
if (index > -1) {
collapsedSites.value.splice(index, 1)
} else {
collapsedSites.value.push(siteId)
}
}
const viewMachineDetails = (machine) => {
navigateTo(`/machine/${machine.id}`)
}
const editMachine = (machine) => {
navigateTo(`/machine/${machine.id}?edit=true`)
}
const { confirm: confirmDialog } = useConfirm()
const confirmDeleteMachine = async (machine) => {
const { showError, showSuccess } = useToast()
if (
await confirmDialog({
message: `Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.`,
})
) {
try {
const result = await deleteMachine(machine.id)
if (result.success) {
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
await loadMachines()
} else {
showError(`Impossible de supprimer la machine : ${result.error}`)
}
} catch (error) {
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
}
}
}
const addMachineToSite = (site) => {
preselectedSiteId.value = site.id
showAddMachineModal.value = true
}
// Lifecycle
onMounted(async () => {
await Promise.all([loadSites(), loadMachines()])
})
</script>

View File

@@ -0,0 +1,323 @@
<template>
<div>
<main class="container mx-auto px-6 py-8">
<!-- Loading State -->
<div v-if="d.loading.value" class="flex justify-center items-center py-16">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
<!-- Machine Details -->
<div v-else-if="d.machine.value" ref="d.printAreaRef" class="space-y-8">
<DocumentPreviewModal
:document="d.previewDocument.value"
:visible="d.previewVisible.value"
:documents="d.machineDocumentsList.value"
@close="d.closePreview"
/>
<!-- Header with actions -->
<MachineDetailHeader
:title="machineViewTitle"
:is-edit-mode="d.isEditMode.value"
@toggle-edit="d.toggleEditMode"
@open-print="d.openPrintModal"
/>
<!-- Debug info -->
<div v-if="d.debug.value" class="bg-yellow-100 p-4 rounded-lg">
<p>Debug: Machine trouvée - {{ d.machine.value.name }}</p>
<p>Components count: {{ d.components.value.length }}</p>
<p>Pieces count: {{ d.pieces.value.length }}</p>
</div>
<!-- Hero -->
<PageHero
:title="d.machine.value.name"
:subtitle="d.machine.value.description"
min-height="min-h-[20vh]"
max-width="max-w-md"
rounded
>
<div class="flex justify-center gap-4">
<div
v-if="d.machine.value.site?.name"
class="badge badge-outline font-semibold"
:style="d.machine.value.site?.color ? { borderColor: d.machine.value.site.color + '60', backgroundColor: d.machine.value.site.color + '25', color: d.machine.value.site.color } : {}"
>
{{ d.machine.value.site?.name }}
</div>
<div v-if="d.machine.value.reference" class="badge badge-outline">
{{ d.machine.value.reference }}
</div>
</div>
</PageHero>
<!-- Machine Info Card -->
<MachineInfoCard
ref="machineInfoCardRef"
:is-edit-mode="d.isEditMode.value"
:machine-name="d.machineName.value"
:machine-reference="d.machineReference.value"
:machine-site-id="d.machineSiteId.value"
:machine-site-name="d.machine.value?.site?.name ?? ''"
:sites="d.sites.value"
:machine-constructeur-ids="d.machineConstructeurIds.value"
:machine-constructeurs-display="d.machineConstructeursDisplay.value"
:has-machine-constructeur="d.hasMachineConstructeur.value"
:constructeur-links="d.constructeurLinks.value"
:visible-custom-fields="d.visibleMachineCustomFields.value"
:get-machine-field-id="d.getMachineFieldId"
:machine-id="machineId"
:machine-custom-field-defs="d.machine.value?.customFields ?? []"
@update:machine-name="d.machineName.value = $event"
@update:machine-reference="d.machineReference.value = $event"
@update:machine-site-id="d.machineSiteId.value = $event"
@update:constructeur-ids="d.handleMachineConstructeurChange"
@update:constructeur-links="d.constructeurLinks.value = $event"
@remove-constructeur-link="handleRemoveConstructeurLink"
@set-custom-field-value="d.setMachineCustomFieldValue"
@custom-fields-saved="() => { d.loadMachineData(); refreshVersions() }"
/>
<!-- Documents -->
<MachineDocumentsCard
v-if="d.isEditMode.value || d.machineDocumentsList.value.length > 0"
:documents="d.machineDocumentsList.value"
:is-edit-mode="d.isEditMode.value"
:uploading="d.machineDocumentsUploading.value"
:files="d.machineDocumentFiles.value"
@update:files="d.machineDocumentFiles.value = $event"
@files-added="d.handleMachineFilesAdded"
@preview="d.openPreview"
@download="d.downloadDocument"
@remove="d.removeMachineDocument"
/>
<!-- Produits associés -->
<MachineProductsCard
v-if="d.isEditMode.value || d.machineDirectProducts.value.length > 0"
:products="d.machineDirectProducts.value"
:is-edit-mode="d.isEditMode.value"
@add-product="openAddModal('product')"
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
/>
<!-- Components Section -->
<MachineComponentsCard
v-if="d.isEditMode.value || d.components.value.length > 0"
:components="d.components.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.componentsCollapsed.value"
:collapse-toggle-token="d.collapseToggleToken.value"
@toggle-collapse="d.toggleAllComponents"
@update-component="d.updateComponent"
@edit-piece="d.updatePieceFromComponent"
@custom-field-update="d.updatePieceCustomField"
@add-component="openAddModal('component')"
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
/>
<!-- Machine Pieces Section -->
<MachinePiecesCard
v-if="d.isEditMode.value || d.machinePieces.value.length > 0"
:pieces="d.machinePieces.value"
:is-edit-mode="d.isEditMode.value"
:collapsed="d.piecesCollapsed.value"
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
@update-piece="d.updatePieceInfo"
@edit-piece="d.editPiece"
@custom-field-update="d.updatePieceCustomField"
@add-piece="openAddModal('piece')"
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
@toggle-collapse="d.toggleAllPieces"
/>
<!-- Add Entity Modal -->
<AddEntityToMachineModal
:open="addModalOpen"
:entity-kind="addModalKind"
@close="addModalOpen = false"
@confirm="handleAddEntity"
/>
<!-- Save / Cancel buttons -->
<div v-if="d.isEditMode.value" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button
type="button"
class="btn btn-ghost"
:class="{ 'btn-disabled': d.saving.value }"
@click="d.cancelEdition()"
>
Annuler
</button>
<button
type="button"
class="btn btn-primary"
:disabled="!d.canSubmit.value"
@click="submitMachineEdition"
>
<span v-if="d.saving.value" class="loading loading-spinner loading-sm mr-2" />
Enregistrer les modifications
</button>
</div>
<!-- Historique -->
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Versions -->
<EntityVersionList
ref="versionListRef"
entity-type="machine"
:entity-id="String(machineId)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="d.loadMachineData()"
/>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="machine"
:entity-id="String(machineId)"
:entity-name="d.machine.value?.name"
show-resolved
/>
</div>
</div>
<!-- Error State -->
<div v-else class="text-center py-12">
<div class="max-w-md mx-auto">
<div class="w-16 h-16 rounded-2xl bg-base-200 grid place-items-center mx-auto mb-5">
<IconLucideAlertTriangle class="w-8 h-8 text-base-content/30" aria-hidden="true" />
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">Machine non trouvée</h3>
<p class="text-sm text-base-content/50 mb-6">La machine avec l'ID "{{ machineId }}" n'existe pas ou a été supprimée.</p>
<button type="button" class="btn btn-primary" @click="$router.back()">
Retour aux machines
</button>
</div>
</div>
</main>
<MachinePrintSelectionModal
:open="d.printModalOpen.value"
:selection="d.printSelection"
:components="d.components.value"
:pieces="d.machinePieces.value"
@close="d.closePrintModal"
@confirm="d.handlePrintConfirm"
@select-all="d.setAllPrintSelection(true)"
@deselect-all="d.setAllPrintSelection(false)"
/>
</div>
</template>
<script setup>
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useMachineDetailData } from '~/composables/useMachineDetailData'
import { useEntityHistory } from '~/composables/useEntityHistory'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import PageHero from '~/components/PageHero.vue'
import MachinePrintSelectionModal from '~/components/MachinePrintSelectionModal.vue'
import MachineDetailHeader from '~/components/machine/MachineDetailHeader.vue'
import MachineInfoCard from '~/components/machine/MachineInfoCard.vue'
import MachineDocumentsCard from '~/components/machine/MachineDocumentsCard.vue'
import MachineProductsCard from '~/components/machine/MachineProductsCard.vue'
import MachineComponentsCard from '~/components/machine/MachineComponentsCard.vue'
import MachinePiecesCard from '~/components/machine/MachinePiecesCard.vue'
import AddEntityToMachineModal from '~/components/machine/AddEntityToMachineModal.vue'
import EntityHistorySection from '~/components/common/EntityHistorySection.vue'
import EntityVersionList from '~/components/common/EntityVersionList.vue'
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
const route = useRoute()
const machineId = route.params.id
const { canEdit } = usePermissions()
if (!machineId) {
console.error('ID de machine manquant')
}
const d = useMachineDetailData(machineId)
const machineInfoCardRef = ref(null)
const versionRefreshKey = ref(0)
const refreshVersions = () => { versionRefreshKey.value++ }
const {
history,
loading: historyLoading,
error: historyError,
loadHistory,
} = useEntityHistory('machine')
const historyFieldLabels = {
name: 'Nom',
reference: 'Référence',
prix: 'Prix',
site: 'Site',
constructeurIds: 'Fournisseurs',
addedComponent: 'Composant ajouté',
removedComponent: 'Composant supprimé',
addedPiece: 'Pièce ajoutée',
removedPiece: 'Pièce supprimée',
addedProduct: 'Produit ajouté',
removedProduct: 'Produit supprimé',
componentLinks: 'Composants liés',
pieceLinks: 'Pièces liées',
productLinks: 'Produits liés',
}
const addModalOpen = ref(false)
const addModalKind = ref('component')
const openAddModal = (kind) => {
addModalKind.value = kind
addModalOpen.value = true
}
const handleRemoveConstructeurLink = (constructeurId) => {
const ids = d.machineConstructeurIds.value.filter(id => id !== constructeurId)
d.handleMachineConstructeurChange(ids)
}
const handleAddEntity = async (entityId) => {
if (addModalKind.value === 'component') {
await d.addComponentLink(entityId)
} else if (addModalKind.value === 'piece') {
await d.addPieceLink(entityId)
} else {
await d.addProductLink(entityId)
}
refreshVersions()
}
const machineViewTitle = computed(() => {
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
})
const submitMachineEdition = async () => {
if (machineInfoCardRef.value?.saveFieldDefinitions) {
await machineInfoCardRef.value.saveFieldDefinitions()
}
await d.submitEdition()
refreshVersions()
}
onMounted(() => {
d.loadMachineData()
d.loadInitialData()
loadHistory(String(machineId)).catch(() => {})
if (route.query.edit === 'true' && canEdit.value) {
d.isEditMode.value = true
}
})
</script>

View File

@@ -0,0 +1,228 @@
<template>
<main class="container mx-auto px-6 py-8">
<div class="my-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">
Parc Machines
</h2>
<NuxtLink to="/machines/new" class="btn btn-primary">
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
Ajouter une machine
</NuxtLink>
</div>
<div class="card bg-base-100 shadow-sm mb-6">
<div class="card-body">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Sites</span>
</label>
<div class="flex flex-wrap gap-3">
<label
v-for="site in sites"
:key="site.id"
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="selectedSites.has(site.id)"
@change="selectedSites.has(site.id) ? selectedSites.delete(site.id) : selectedSites.add(site.id)"
>
<span class="text-sm">{{ site.name }}</span>
</label>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Recherche</span>
</label>
<input
v-model="searchQuery"
type="text"
placeholder="Rechercher par nom ou référence..."
class="input input-bordered"
>
</div>
</div>
</div>
</div>
<div v-if="loading" class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="filteredMachines.length === 0" class="text-center py-12">
<div class="max-w-md mx-auto">
<IconLucideFactory class="w-16 h-16 mx-auto text-gray-400 mb-4" aria-hidden="true" />
<h3 class="text-lg font-medium text-gray-900 mb-2">
Aucune machine trouvée
</h3>
<p class="text-gray-500 mb-4">
Commencez par ajouter votre première machine.
</p>
<NuxtLink to="/machines/new" class="btn btn-primary">
Ajouter une machine
</NuxtLink>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="machine in filteredMachines"
:key="machine.id"
class="card site-card shadow-md hover:shadow-xl transition-shadow cursor-pointer overflow-hidden"
:style="{
borderTop: machine.site?.color ? `4px solid ${machine.site.color}` : '4px solid transparent',
background: machine.site?.color ? `linear-gradient(160deg, ${machine.site.color}30 0%, ${machine.site.color}08 40%, var(--color-base-100) 100%)` : undefined,
}"
@click="viewMachineDetails(machine)"
>
<div class="card-body flex flex-col">
<div class="flex items-center justify-between mb-2">
<h3 class="card-title text-lg">
{{ machine.name }}
</h3>
</div>
<div class="space-y-2 text-sm">
<div class="flex items-center gap-2">
<IconLucideMapPin class="w-4 h-4" :style="{ color: machine.site?.color || '#3b82f6' }" aria-hidden="true" />
<span
class="font-bold text-sm px-2.5 py-1 rounded-lg text-base-content"
:style="machine.site?.color ? { backgroundColor: machine.site.color + '30', border: `1px solid ${machine.site.color}40` } : {}"
>{{ machine.site?.name || 'Site inconnu' }}</span>
</div>
<div v-if="machine.reference" class="flex items-center gap-2">
<IconLucideTag class="w-4 h-4 text-orange-500" aria-hidden="true" />
<span class="text-gray-600">{{ machine.reference }}</span>
</div>
</div>
<div class="mt-auto pt-3 flex items-center justify-end gap-2">
<button v-if="canEdit" class="btn btn-ghost btn-sm" @click.stop="editMachine(machine)">
Modifier
</button>
<button v-if="canEdit" class="btn btn-ghost btn-sm text-error" @click.stop="confirmDeleteMachine(machine)">
Supprimer
</button>
<NuxtLink :to="`/machine/${machine.id}`" class="btn btn-primary btn-sm">
Détails
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</main>
</template>
<script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useUrlState } from '~/composables/useUrlState'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideFactory from '~icons/lucide/factory'
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucideTag from '~icons/lucide/tag'
const { canEdit } = usePermissions()
const { machines, loading, loadMachines, deleteMachine } = useMachines()
const { sites, loadSites } = useSites()
const toast = useToast()
const urlState = useUrlState({
q: { default: '', debounce: 300 },
sites: { default: '' },
})
const searchQuery = urlState.q
const selectedSites = reactive(new Set())
// Sync URL → selectedSites on load and back/forward
watch(urlState.sites, (val) => {
selectedSites.clear()
if (val) {
for (const id of String(val).split(',')) {
if (id) selectedSites.add(id)
}
}
}, { immediate: true })
// Sync selectedSites → URL
watch(() => [...selectedSites], (ids) => {
urlState.sites.value = ids.join(',')
})
// Enrichir les machines avec les objets site complets
const enrichedMachines = computed(() => {
return machines.value.map((machine) => {
const site = sites.value.find(s => s.id === machine.siteId)
return {
...machine,
site: site || null,
}
})
})
const filteredMachines = computed(() => {
let filtered = enrichedMachines.value
if (selectedSites.size > 0) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId))
}
if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine =>
machine.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term),
)
}
filtered = [...filtered].sort((a, b) =>
(a.name || '').localeCompare(b.name || '', 'fr')
)
return filtered
})
const viewMachineDetails = (machine) => {
navigateTo(`/machine/${machine.id}`)
}
const editMachine = (machine) => {
navigateTo(`/machine/${machine.id}?edit=true`)
}
const { confirm: confirmDialog } = useConfirm()
const confirmDeleteMachine = async (machine) => {
const { showError, showSuccess } = toast
if (await confirmDialog({ message: `Êtes-vous sûr de vouloir supprimer la machine "${machine.name}" ? Cette action est irréversible.` })) {
try {
const result = await deleteMachine(machine.id)
if (result.success) {
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
} else {
showError(`Impossible de supprimer la machine : ${result.error}`)
}
} catch (error) {
showError(`Impossible de supprimer la machine : ${humanizeError(error.message)}`)
}
}
}
onMounted(async () => {
await Promise.all([
loadMachines(),
loadSites(),
])
})
</script>

View File

@@ -0,0 +1,121 @@
<template>
<main class="container mx-auto px-6 py-8">
<div class="max-w-3xl mx-auto">
<div class="flex flex-wrap items-center justify-between gap-3 mb-6">
<div>
<h1 class="text-2xl font-bold">
Nouvelle machine
</h1>
<p class="text-sm text-gray-500">
Renseignez les informations de base pour créer la machine.
</p>
</div>
<NuxtLink to="/machines" class="btn btn-ghost">
Annuler
</NuxtLink>
</div>
<div v-if="c.loading" class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg" />
</div>
<form v-else class="space-y-6" @submit.prevent="c.finalizeMachineCreation">
<div class="card bg-base-100 shadow-sm">
<div class="card-body space-y-6">
<!-- Basic fields -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="machine-field-name">
<span class="label-text">Nom de la machine <span class="text-error">*</span></span>
</label>
<input
id="machine-field-name"
v-model="c.newMachine.name"
type="text"
placeholder="Ex: Presse hydraulique #1"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit"
required
>
</div>
<div class="form-control">
<label class="label" for="machine-field-site">
<span class="label-text">Site <span class="text-error">*</span></span>
</label>
<select
id="machine-field-site"
v-model="c.newMachine.siteId"
class="select select-bordered select-sm md:select-md"
:disabled="!canEdit"
required
>
<option value="">
Sélectionner un site
</option>
<option v-for="site in c.sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="machine-field-reference">
<span class="label-text">Référence</span>
</label>
<input
id="machine-field-reference"
v-model="c.newMachine.reference"
type="text"
placeholder="Ex: PRESS-001"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit"
>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Cloner depuis une machine</span>
</label>
<SearchSelect
v-model="c.newMachine.cloneFromMachineId"
:options="c.machines"
:disabled="!canEdit"
:clearable="true"
placeholder="Rechercher une machine..."
empty-text="Aucune machine trouvée"
/>
</div>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4 border-t border-base-200">
<NuxtLink to="/machines" class="btn btn-outline btn-sm md:btn-md">
Annuler
</NuxtLink>
<button
type="submit"
class="btn btn-sm md:btn-md btn-primary"
:disabled="!canEdit || !c.newMachine.name?.trim() || c.submitting"
:class="{ loading: c.submitting }"
>
Créer la machine
</button>
</div>
</div>
</div>
</form>
</div>
</main>
</template>
<script setup>
import { proxyRefs } from 'vue'
import { useMachineCreatePage } from '~/composables/useMachineCreatePage'
import SearchSelect from '~/components/common/SearchSelect.vue'
const c = proxyRefs(useMachineCreatePage())
const { canEdit } = usePermissions()
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="min-h-[60vh] flex flex-col items-center justify-center gap-6 text-center px-6">
<div class="space-y-2 max-w-xl">
<h1 class="text-3xl font-semibold">Catalogue retiré</h1>
<p class="text-sm text-gray-500">
La gestion des modèles s'effectue désormais directement depuis les catégories de composants et de pièces.
Retrouvez l'intégralité des squelettes et champs personnalisés dans ces écrans dédiés.
</p>
</div>
<div class="flex flex-wrap items-center justify-center gap-3">
<NuxtLink to="/component-category" class="btn btn-primary">
Gestion des catégories de composants
</NuxtLink>
<NuxtLink to="/piece-category" class="btn btn-outline">
Gestion des catégories de pièces
</NuxtLink>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
onMounted(() => {
navigateTo('/component-category', { replace: true })
})
</script>

View File

@@ -0,0 +1,204 @@
<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="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">{{ title }}</h1>
<p class="text-base text-base-content/70">
Mettez à jour la structure et les champs personnalisés de cette catégorie de pièce pour préparer les futures créations.
</p>
</div>
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</button>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<div v-if="loading" class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement de la catégorie</span>
</div>
<ModelTypeForm
v-else
mode="edit"
initial-category="PIECE"
:initial-data="initialData"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="piece_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { PieceModelStructure } from '~/shared/types/inventory'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadPieceTypes } = usePieceTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
)
useHead(() => ({
title: title.value,
}))
const navigateBackToList = async () => {
await router.push('/piece-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const normalizeError = (error: any) => {
const message = error?.data?.message || error?.message || 'Une erreur est survenue.'
return Array.isArray(message) ? message[0] : message
}
const loadCategory = async () => {
loading.value = true
try {
const id = String(route.params.id)
const response = await getModelType(id)
if (response.category !== 'PIECE') {
showError("Cette catégorie n'est pas une pièce.")
await navigateBackToList()
return
}
initialData.value = {
name: response.name,
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
structure: (response.structure as PieceModelStructure | null) ?? undefined,
referenceFormula: response.referenceFormula ?? null,
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
}
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
} finally {
loading.value = false
}
}
const handleCancel = () => {
navigateBackToList()
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
const id = String(route.params.id)
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload?.notes ?? null,
}
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
}
} catch (error) {
showError(normalizeError(error))
} finally {
saving.value = false
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadPieceTypes({ force: true })
showSuccess('Catégorie de pièce mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})
</script>

View File

@@ -0,0 +1,10 @@
<template>
<ManagementView
category="PIECE"
heading="Catégories de pièce"
/>
</template>
<script setup lang="ts">
import ManagementView from '~/components/model-types/ManagementView.vue'
</script>

View File

@@ -0,0 +1,74 @@
<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="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">Nouvelle catégorie de pièce</h1>
<p class="text-base text-base-content/70">
Définissez les champs personnalisés et le squelette appliqué lors de la création des pièces de cette catégorie.
</p>
</div>
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</button>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<ModelTypeForm
mode="create"
initial-category="PIECE"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({
title: 'Nouvelle catégorie de pièce',
}))
const router = useRouter()
const { showError, showSuccess } = useToast()
const saving = ref(false)
const handleCancel = () => {
router.push('/piece-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('PIECE')
showSuccess('Catégorie de pièce créée avec succès.')
await router.push('/piece-category')
} catch (error: any) {
const message = error?.data?.message || error?.message || 'Une erreur est survenue lors de la création.'
showError(Array.isArray(message) ? message[0] : message)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,506 @@
<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">
<DetailHeader
:title="isEditMode ? 'Modifier la pièce' : piece.name"
:subtitle="isEditMode ? 'Ajustez les informations de la pièce et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/pieces-catalog"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<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>
<template v-if="isEditMode">
<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>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ selectedType?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<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-if="isEditMode"
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 v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.name }}
</div>
</div>
</div>
<!-- Description (if value or edit mode) -->
<div v-if="isEditMode || piece.description" class="form-control">
<label class="label">
<span class="label-text">Description</span>
</label>
<textarea
v-if="isEditMode"
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 v-else class="textarea textarea-bordered textarea-sm md:textarea-md bg-base-200">
{{ piece.description }}
</div>
</div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="piece.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || piece.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
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 v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.reference }}
</div>
</div>
<div v-if="isEditMode || constructeurLinks.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseur</span>
</label>
<template v-if="isEditMode">
<ConstructeurSelect
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="piece?.constructeurs || []"
/>
<ConstructeurLinksTable
v-model="constructeurLinks"
class="mt-2"
@remove="handleConstructeurRemoved"
/>
</template>
<ConstructeurLinksTable
v-else
:model-value="constructeurLinks"
readonly
/>
</div>
</div>
<!-- Prix (if value or edit mode) -->
<div v-if="isEditMode || piece.prix" 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-if="isEditMode"
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 v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ piece.prix }}
</div>
</div>
</div>
<!-- Product requirements -->
<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">
Produits liés
</h2>
<p class="text-xs text-base-content/70">
{{ isEditMode ? 'Cette pièce doit rester liée à un produit catalogue répondant aux critères suivants.' : 'Produits associés à cette pièce via le squelette.' }}
</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 v-if="isEditMode" 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 v-else class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div
v-for="(entry, index) in productRequirementEntries"
:key="entry.key"
class="form-control"
>
<label class="label">
<span class="label-text text-xs font-medium">{{ entry.label }}</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ productSelectionLabels[index] || '— Non sélectionné' }}
</div>
</div>
</div>
</div>
<!-- Skeleton preview (edit mode only) -->
<StructureSkeletonPreview
v-if="isEditMode && (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"
/>
<!-- Custom fields -->
<div v-if="visibleCustomFields.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 v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à cette pièce.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || pieceDocuments.length > 0"
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">
{{ isEditMode ? 'Gérez les documents associés à cette pièce.' : 'Documents associés à cette pièce.' }}
</p>
</div>
<span v-if="isEditMode && 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>
<template v-if="isEditMode">
<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"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours…
</p>
<DocumentListInline
v-else
:documents="pieceDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à cette pièce pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<EntityVersionList
entity-type="piece"
:entity-id="String(route.params.id)"
:field-labels="historyFieldLabels"
:refresh-key="versionRefreshKey"
@restored="fetchPiece()"
/>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
</button>
<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 { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from '#imports'
import { usePieceEdit } from '~/composables/usePieceEdit'
import { useDocuments } from '~/composables/useDocuments'
import { usePermissions } from '~/composables/usePermissions'
const route = useRoute()
const { canEdit } = usePermissions()
const { updateDocument } = useDocuments()
const isEditMode = ref(false)
const versionRefreshKey = ref(0)
const {
piece,
loading,
saving,
selectedFiles,
uploadingDocuments,
loadingDocuments,
pieceDocuments,
previewDocument,
previewVisible,
selectedTypeId,
editionForm,
constructeurLinks,
productSelections,
customFieldInputs,
pieceTypeList,
selectedType,
resolvedStructure,
structureProducts,
productRequirementDescriptions,
productRequirementEntries,
canSubmit,
historyFieldLabels,
history,
historyLoading,
historyError,
openPreview,
closePreview,
removeDocument,
handleFilesAdded,
setProductSelection,
submitEdition: _submitEdition,
fetchPiece,
formatPieceStructurePreview,
} = usePieceEdit(String(route.params.id))
const submitEdition = async () => {
await _submitEdition()
if (!saving.value) {
await fetchPiece()
isEditMode.value = false
versionRefreshKey.value++
}
}
// Sync ConstructeurSelect changes → constructeurLinks
watch(() => editionForm.constructeurIds, (newIds) => {
const existing = new Map(constructeurLinks.value.map(l => [l.constructeurId, l]))
constructeurLinks.value = newIds.map(id =>
existing.get(id) || { constructeurId: id, supplierReference: null },
)
})
const handleConstructeurRemoved = (constructeurId: string) => {
editionForm.constructeurIds = editionForm.constructeurIds.filter(id => id !== constructeurId)
}
const editingDocument = ref<any | null>(null)
const editModalVisible = ref(false)
// Resolve product names for read-only display from piece data
const productSelectionLabels = computed(() => {
if (!piece.value) return []
const p = piece.value as any
// piece.product contains {id, name} for the legacy single product
if (p.product?.name) return [p.product.name]
return productSelections.value.map((id: string | null) => id || null)
})
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(
(f) => f.value !== null && f.value !== undefined && f.value !== '',
)
})
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
}
onMounted(() => {
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>

View File

@@ -0,0 +1,245 @@
<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

@@ -0,0 +1,357 @@
<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

@@ -0,0 +1,519 @@
<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">
<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"
/>
<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
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>
<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 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>
<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' : '' }} à ê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>
<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 }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
<span v-if="submitting" class="loading loading-spinner loading-sm mr-2"></span>
Créer la pièce
</button>
</div>
</div>
</section>
</main>
</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 ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import ProductSelect from '~/components/ProductSelect.vue'
import SearchSelect from '~/components/common/SearchSelect.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { usePieces } from '~/composables/usePieces'
import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useConstructeurs } from '~/composables/useConstructeurs'
import { formatPieceStructurePreview } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import type { PieceModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import {
getStructureProducts,
buildProductRequirementDescriptions,
buildProductRequirementEntries,
resizeProductSelections,
areProductSelectionsFilled,
applyProductSelection,
collectNormalizedProductIds,
} from '~/shared/utils/pieceProductSelectionUtils'
import {
type CustomFieldInput,
normalizeCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
interface PieceCatalogType extends ModelType {
structure: PieceModelStructure | null
customFields?: Array<Record<string, any>>
}
const route = useRoute()
const router = useRouter()
const { pieceTypes, loadPieceTypes, loadingPieceTypes: loadingTypes } = usePieceTypes()
const { createPiece } = usePieces()
const toast = useToast()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const { canEdit } = usePermissions()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
description: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
prix: '' as string,
})
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const productSelections = ref<(string | null)[]>([])
const lastSuggestedName = ref('')
const customFieldInputs = ref<CustomFieldInput[]>([])
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
watch(
() => route.query.typeId,
(value) => {
if (typeof value === 'string') {
selectedTypeId.value = value
}
},
)
watch(selectedTypeId, (id) => {
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
if ((id || '') === current) {
return
}
const nextQuery = { ...route.query }
if (id) {
nextQuery.typeId = id
} else {
delete nextQuery.typeId
}
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
const pieceTypeList = computed<PieceCatalogType[]>(() => (pieceTypes.value || []) as PieceCatalogType[])
const typeOptionLabel = (type?: PieceCatalogType) =>
type?.name || 'Catégorie'
const typeOptionDescription = (type?: PieceCatalogType) =>
type?.description ? String(type.description) : ''
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
const structureProducts = computed(() =>
getStructureProducts(selectedType.value?.structure ?? null),
)
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
const productRequirementDescriptions = computed(() =>
buildProductRequirementDescriptions(structureProducts.value),
)
const ensureProductSelections = (count: number) => {
productSelections.value = resizeProductSelections(productSelections.value, count)
}
const productRequirementEntries = computed(() =>
buildProductRequirementEntries(structureProducts.value, 'piece-create-product-requirement'),
)
const productSelectionsFilled = computed(() =>
areProductSelectionsFilled(
requiresProductSelection.value,
productRequirementEntries.value,
productSelections.value,
),
)
const setProductSelection = (index: number, value: string | null) => {
productSelections.value = applyProductSelection(productSelections.value, index, value)
}
watch(structureProducts, (products) => {
ensureProductSelections(products.length)
})
watch(selectedType, (type) => {
if (!type) {
clearCreationForm()
customFieldInputs.value = []
return
}
if (!creationForm.name || creationForm.name === lastSuggestedName.value) {
creationForm.name = type.name
}
lastSuggestedName.value = creationForm.name
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(
canEdit.value &&
selectedType.value &&
creationForm.name &&
requiredCustomFieldsFilled.value &&
productSelectionsFilled.value &&
!submitting.value,
),
)
const clearCreationForm = () => {
creationForm.name = ''
creationForm.description = ''
creationForm.reference = ''
creationForm.constructeurIds = []
constructeurLinks.value = []
creationForm.prix = ''
productSelections.value = []
lastSuggestedName.value = ''
}
const submitCreation = async () => {
if (!selectedType.value) {
toast.showError('Sélectionnez une catégorie de pièce.')
return
}
if (!productSelectionsFilled.value) {
toast.showError('Sélectionnez un produit conforme au squelette.')
return
}
const payload: Record<string, any> = {
name: creationForm.name.trim(),
typePieceId: selectedType.value.id,
}
const description = creationForm.description.trim()
if (description) {
payload.description = description
}
const reference = creationForm.reference.trim()
if (reference) {
payload.reference = reference
}
const normalizedProductIds = collectNormalizedProductIds(
productRequirementEntries.value,
productSelections.value,
)
if (normalizedProductIds.length) {
payload.productIds = normalizedProductIds
payload.productId = normalizedProductIds[0]
}
const rawPrice = typeof creationForm.prix === 'string'
? creationForm.prix.trim()
: creationForm.prix === null || creationForm.prix === undefined
? ''
: String(creationForm.prix).trim()
if (rawPrice) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.prix = String(parsed)
}
}
submitting.value = true
try {
const result = await createPiece(payload)
if (result.success && result.data) {
const createdPiece = result.data as Record<string, any>
await _saveCustomFieldValues(
'piece',
createdPiece.id,
[
createdPiece?.typePiece?.structure?.customFields,
],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
// Sync constructeur links after creation
if (constructeurLinks.value.length) {
await syncLinks('piece', createdPiece.id, [], constructeurLinks.value)
}
if (selectedDocuments.value.length && createdPiece.id) {
uploadingDocuments.value = true
const uploadResult = await uploadDocuments(
{
files: selectedDocuments.value,
context: { pieceId: createdPiece.id },
},
{ updateStore: false },
)
if (!uploadResult.success) {
const message = uploadResult.error
? `Documents non ajoutés : ${uploadResult.error}`
: 'Documents non ajoutés : une erreur est survenue.'
toast.showError(message)
}
selectedDocuments.value = []
}
toast.showSuccess('Pièce créée avec succès')
await router.replace(`/piece/${createdPiece.id}?edit=true`)
} else if (result.error) {
toast.showError(result.error)
}
} catch (error: any) {
toast.showError(humanizeError(error?.message) || 'Impossible de créer la pièce')
} finally {
submitting.value = false
uploadingDocuments.value = false
}
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.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))
},
{ deep: true },
)
onMounted(async () => {
await loadPieceTypes()
})
</script>

View File

@@ -0,0 +1,257 @@
<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

@@ -0,0 +1,202 @@
<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="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">{{ title }}</h1>
<p class="text-base text-base-content/70">
Mettez à jour la structure et les champs personnalisés de cette catégorie de produit pour préparer les futures créations.
</p>
</div>
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</button>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<div v-if="loading" class="flex items-center justify-center py-16">
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
<span class="ml-3 text-sm text-base-content/70">Chargement de la catégorie</span>
</div>
<ModelTypeForm
v-else
mode="edit"
initial-category="PRODUCT"
:initial-data="initialData"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
<!-- Comments -->
<div class="mt-4">
<CommentSection
entity-type="product_category"
:entity-id="String(route.params.id)"
:entity-name="initialData?.name"
show-resolved
/>
</div>
<SyncConfirmationModal
:preview="syncPreviewData"
:open="showSyncModal"
:loading="syncLoading"
@confirm="handleSyncConfirm"
@cancel="handleSyncCancel"
/>
</main>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useHead, useRoute, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { getModelType, updateModelType, syncPreview, syncExecute, type ModelTypePayload, type SyncPreviewResult } from '~/services/modelTypes'
import type { ProductModelStructure } from '~/shared/types/inventory'
import { useProductTypes } from '~/composables/useProductTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const { showError, showSuccess } = useToast()
const { loadProductTypes } = useProductTypes()
const loading = ref(true)
const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const showSyncModal = ref(false)
const syncLoading = ref(false)
const syncPreviewData = ref<SyncPreviewResult | null>(null)
const pendingPayload = ref<Partial<ModelTypePayload> | null>(null)
const title = computed(() =>
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
)
useHead(() => ({
title: title.value,
}))
const navigateBackToList = async () => {
await router.push('/product-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const normalizeError = (error: any) => {
const message = error?.data?.message || error?.message || 'Une erreur est survenue.'
return Array.isArray(message) ? message[0] : message
}
const loadCategory = async () => {
loading.value = true
try {
const id = String(route.params.id)
const response = await getModelType(id)
if (response.category !== 'PRODUCT') {
showError("Cette catégorie n'est pas un produit.")
await navigateBackToList()
return
}
initialData.value = {
name: response.name,
code: response.code,
category: response.category,
notes: response.notes ?? response.description ?? '',
structure: (response.structure as ProductModelStructure | null) ?? undefined,
}
} catch (error) {
showError(normalizeError(error))
await navigateBackToList()
} finally {
loading.value = false
}
}
const handleCancel = () => {
navigateBackToList()
}
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
if (!canEdit.value) return
const id = String(route.params.id)
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload?.notes ?? null,
}
// Get sync preview BEFORE saving
const preview = await syncPreview(id, enrichedPayload.structure || {})
const hasImpact = preview && (
Object.values(preview.additions || {}).some(v => v > 0)
|| Object.values(preview.deletions || {}).some(v => v > 0)
|| Object.values(preview.modifications || {}).some(v => v > 0)
)
if (hasImpact) {
// Show modal for confirmation
pendingPayload.value = enrichedPayload
syncPreviewData.value = preview
showSyncModal.value = true
} else {
// No impact — save directly + sync
await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
}
} catch (error) {
showError(normalizeError(error))
} finally {
saving.value = false
}
}
const handleSyncConfirm = async () => {
if (!pendingPayload.value) return
const id = String(route.params.id)
syncLoading.value = true
try {
const hasDeletions = syncPreviewData.value && Object.values(syncPreviewData.value.deletions || {}).some(v => v > 0)
const hasModifications = syncPreviewData.value && Object.values(syncPreviewData.value.modifications || {}).some(v => v > 0)
await updateModelType(id, pendingPayload.value)
await syncExecute(id, {
confirmDeletions: !!hasDeletions,
confirmTypeChanges: !!hasModifications,
})
await loadProductTypes({ force: true })
showSuccess('Catégorie de produit mise à jour avec succès.')
} catch (error) {
showError(normalizeError(error))
} finally {
syncLoading.value = false
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
}
const handleSyncCancel = () => {
showSyncModal.value = false
pendingPayload.value = null
syncPreviewData.value = null
}
onMounted(() => {
loadCategory()
})
</script>

View File

@@ -0,0 +1,11 @@
<template>
<ManagementView
category="PRODUCT"
heading="Catégories de produit"
description="Gérez les catégories de produits et leurs champs personnalisés communs."
/>
</template>
<script setup lang="ts">
import ManagementView from '~/components/model-types/ManagementView.vue'
</script>

View File

@@ -0,0 +1,74 @@
<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="space-y-2">
<div class="flex items-center justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-base-content">Nouvelle catégorie de produit</h1>
<p class="text-base text-base-content/70">
Définissez les champs personnalisés et le squelette appliqué lors de la création des produits de cette catégorie.
</p>
</div>
<button type="button" class="btn btn-ghost" @click="$router.back()">
Retour au catalogue
</button>
</div>
</header>
<section class="rounded-xl border border-base-300 bg-base-100 p-6 shadow-sm">
<ModelTypeForm
mode="create"
initial-category="PRODUCT"
:lock-category="true"
:saving="saving"
:readonly="!canEdit"
@submit="handleSubmit"
@cancel="handleCancel"
/>
</section>
</main>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useHead, useRouter } from '#imports'
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
import { createModelType } from '~/services/modelTypes'
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
import { useToast } from '~/composables/useToast'
const { canEdit } = usePermissions()
useHead(() => ({
title: 'Nouvelle catégorie de produit',
}))
const router = useRouter()
const { showError, showSuccess } = useToast()
const saving = ref(false)
const handleCancel = () => {
router.push('/product-category').catch(() => {
showError("Navigation impossible vers la liste des catégories.")
})
}
const handleSubmit = async (payload: Parameters<typeof createModelType>[0]) => {
if (!canEdit.value) return
saving.value = true
try {
const enrichedPayload = {
...payload,
description: payload.notes ?? null,
}
await createModelType(enrichedPayload)
invalidateEntityTypeCache('PRODUCT')
showSuccess('Catégorie de produit créée avec succès.')
await router.push('/product-category')
} catch (error: any) {
const message = error?.data?.message || error?.message || 'Une erreur est survenue lors de la création.'
showError(Array.isArray(message) ? message[0] : message)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1,564 @@
<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 { useCustomFields } from '~/composables/useCustomFields'
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 {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
const { canEdit } = usePermissions()
const versionRefreshKey = ref(0)
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
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 customFieldInputs = ref<CustomFieldInput[]>([])
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
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
}
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
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 _saveCustomFieldValues(
'product',
result.data.id,
[result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
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

@@ -0,0 +1,646 @@
<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-20 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-5xl mx-auto">
<div class="card-body space-y-6">
<DetailHeader
:title="isEditMode ? 'Modifier le produit' : product.name"
:subtitle="isEditMode ? 'Ajustez les informations du produit et ses champs personnalisés.' : undefined"
:is-edit-mode="isEditMode"
:can-edit="canEdit"
back-link="/product-catalog"
@toggle-edit="isEditMode = !isEditMode"
/>
<!-- Catégorie (always shown) -->
<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>
<template v-if="isEditMode">
<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>
</template>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product?.typeProduct?.name || '—' }}
</div>
</div>
</div>
<!-- Nom (always shown) -->
<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-if="isEditMode"
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 v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.name }}
</div>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) -->
<div
v-if="isEditMode || product.reference || editionForm.constructeurIds.length"
class="grid grid-cols-1 gap-4 md:grid-cols-2"
>
<div v-if="isEditMode || product.reference" class="form-control">
<label class="label">
<span class="label-text">Référence</span>
</label>
<input
v-if="isEditMode"
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 v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.reference }}
</div>
</div>
<div v-if="isEditMode || editionForm.constructeurIds.length" class="form-control">
<label class="label">
<span class="label-text">Fournisseurs</span>
</label>
<ConstructeurSelect
v-if="isEditMode"
v-model="editionForm.constructeurIds"
class="w-full"
:disabled="!canEdit || saving"
placeholder="Rechercher un ou plusieurs fournisseurs..."
:initial-options="product?.constructeurs || []"
/>
<div v-else class="flex flex-wrap gap-2">
<span
v-for="id in editionForm.constructeurIds"
:key="id"
class="badge badge-outline"
>
{{ getConstructeurById(id)?.name || id }}
</span>
</div>
</div>
</div>
<!-- Constructeur links table -->
<ConstructeurLinksTable
v-if="isEditMode && constructeurLinks.length"
v-model="constructeurLinks"
/>
<ConstructeurLinksTable
v-else-if="!isEditMode && constructeurLinks.length"
:model-value="constructeurLinks"
:readonly="true"
/>
<!-- Prix fournisseur (if value or edit mode) -->
<div v-if="isEditMode || product.supplierPrice" 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-if="isEditMode"
v-model="editionForm.supplierPrice"
type="number"
step="0.01"
min="0"
class="input input-bordered input-sm md:input-md"
:disabled="!canEdit || saving"
placeholder="Valeur indicatrice"
>
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
{{ product.supplierPrice }}
</div>
</div>
</div>
<!-- Structure preview (edit mode only) -->
<div v-if="isEditMode && 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>
<!-- Custom fields -->
<div v-if="visibleCustomFields.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 v-if="isEditMode" class="text-xs text-base-content/70">
Mettez à jour les valeurs propres à ce produit.
</p>
</header>
<template v-if="isEditMode">
<CustomFieldInputGrid :fields="customFieldInputs" :disabled="!canEdit || saving" />
</template>
<template v-else>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="field in visibleCustomFields"
:key="field.customFieldValueId || field.id || field.name"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{ field.name }}</span>
</label>
<div class="input input-bordered input-sm bg-base-200 flex items-center">
{{ field.value }}
</div>
</div>
</div>
</template>
</div>
<!-- Documents -->
<div
v-if="isEditMode || productDocuments.length > 0"
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">
{{ isEditMode ? 'Gérez les documents associés à ce produit.' : 'Documents associés à ce produit.' }}
</p>
</div>
<span v-if="isEditMode && 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>
<template v-if="isEditMode">
<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="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"
/>
</template>
<template v-else>
<p v-if="loadingDocuments" class="text-xs text-base-content/70">
Chargement des documents en cours
</p>
<DocumentListInline
v-else
:documents="productDocuments"
:can-delete="false"
:can-edit="false"
empty-text="Aucun document n'est associé à ce produit pour le moment."
@preview="openPreview"
/>
</template>
</div>
<EntityHistorySection
:entries="history"
:loading="historyLoading"
:error="historyError"
:field-labels="historyFieldLabels"
/>
<!-- Save buttons (edit mode only) -->
<div v-if="isEditMode" class="flex flex-col gap-3 md:flex-row md:justify-end">
<button type="button" class="btn btn-ghost" :class="{ 'btn-disabled': saving }" @click="isEditMode = false">
Annuler
</button>
<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="isEditMode && 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 { useCustomFields } from '~/composables/useCustomFields'
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 { usePermissions } from '~/composables/usePermissions'
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 {
type CustomFieldInput,
buildCustomFieldInputs,
requiredCustomFieldsFilled as _requiredCustomFieldsFilled,
saveCustomFieldValues as _saveCustomFieldValues,
} from '~/shared/utils/customFieldFormUtils'
const { canEdit } = usePermissions()
const route = useRoute()
const router = useRouter()
const toast = useToast()
const { getProduct, updateProduct } = useProducts()
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
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 isEditMode = ref(false)
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const originalConstructeurLinks = ref<ConstructeurLinkEntry[]>([])
const product = ref<any | null>(null)
const productType = ref<any | null>(null)
const structure = ref<ProductModelStructure | null>(null)
const customFieldInputs = ref<CustomFieldInput[]>([])
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 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
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
}
const editionForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const requiredCustomFieldsFilled = computed(() =>
_requiredCustomFieldsFilled(customFieldInputs.value),
)
const canSubmit = computed(() =>
Boolean(canEdit.value && product.value && editionForm.name.trim().length >= 2 && requiredCustomFieldsFilled.value && !saving.value),
)
const structurePreview = computed(() => formatProductStructurePreview(structure.value))
const visibleCustomFields = computed(() => {
if (isEditMode.value) return customFieldInputs.value
return customFieldInputs.value.filter(
(f) => f.value !== null && f.value !== undefined && f.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()
const customValues = Array.isArray(result.data?.customFieldValues) ? result.data.customFieldValues : []
refreshCustomFieldInputs(undefined, customValues)
hydrateForm()
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 () => {
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 _saveCustomFieldValues(
'product',
result.data.id,
[result.data?.typeProduct?.structure?.customFields],
{ customFieldInputs, upsertCustomFieldValue, updateCustomFieldValue, toast },
)
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')
await loadProduct()
isEditMode.value = false
}
} 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()
if (route.query.edit === 'true' && canEdit.value) {
isEditMode.value = true
}
})
</script>

View File

@@ -0,0 +1,439 @@
<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">
<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"
/>
<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>
<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 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>
</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>
<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 }">
Annuler
</NuxtLink>
<button type="button" class="btn btn-primary" :disabled="!canSubmit" @click="submitCreation">
<span v-if="submitting" class="loading loading-spinner loading-sm mr-2"></span>
Créer le produit
</button>
</div>
<p v-if="selectedType && !requiredCustomFieldsFilled" class="text-xs text-error text-right">
Merci de renseigner tous les champs personnalisés obligatoires.
</p>
</div>
</section>
</main>
</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 SearchSelect from '~/components/common/SearchSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import { useProductTypes } from '~/composables/useProductTypes'
import { useProducts } from '~/composables/useProducts'
import { useToast } from '~/composables/useToast'
import { useCustomFields } from '~/composables/useCustomFields'
import { useDocuments } from '~/composables/useDocuments'
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
import { constructeurIdsFromLinks } from '~/shared/constructeurUtils'
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
import { useConstructeurLinks } from '~/composables/useConstructeurLinks'
import { useConstructeurs } from '~/composables/useConstructeurs'
import type { ProductModelStructure } from '~/shared/types/inventory'
import type { ModelType } from '~/services/modelTypes'
import {
type CustomFieldInput,
normalizeCustomFieldInputs as normalizeCustomFieldInputsFromUtils,
} from '~/shared/utils/customFieldFormUtils'
interface ProductCatalogType extends ModelType {
structure: ProductModelStructure | null
customFields?: Array<Record<string, any>>
}
const route = useRoute()
const router = useRouter()
const { productTypes, loadProductTypes, loadingProductTypes: loadingTypes } = useProductTypes()
const { createProduct } = useProducts()
const toast = useToast()
const { upsertCustomFieldValue } = useCustomFields()
const { uploadDocuments } = useDocuments()
const { canEdit } = usePermissions()
const { syncLinks } = useConstructeurLinks()
const { getConstructeurById } = useConstructeurs()
const initialTypeId = ref<string>(typeof route.query.typeId === 'string' ? route.query.typeId : '')
const selectedTypeId = ref<string>(initialTypeId.value)
const submitting = ref(false)
const creationForm = reactive({
name: '' as string,
reference: '' as string,
constructeurIds: [] as string[],
supplierPrice: '' as string,
})
const constructeurLinks = ref<ConstructeurLinkEntry[]>([])
const selectedDocuments = ref<File[]>([])
const uploadingDocuments = ref(false)
const customFieldInputs = ref<CustomFieldInput[]>([])
const productTypeList = computed<ProductCatalogType[]>(() =>
(productTypes.value || []) as ProductCatalogType[],
)
const typeOptionLabel = (type?: ProductCatalogType) => type?.name || 'Catégorie'
const typeOptionDescription = (type?: ProductCatalogType) =>
type?.description ? String(type.description) : ''
const selectedType = computed(() => {
if (!selectedTypeId.value) {
return null
}
return productTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
})
watch(
() => route.query.typeId,
(value) => {
if (typeof value === 'string') {
selectedTypeId.value = value
}
},
)
watch(selectedTypeId, (id) => {
const current = typeof route.query.typeId === 'string' ? route.query.typeId : ''
if ((id || '') === current) {
return
}
const nextQuery = { ...route.query }
if (id) {
nextQuery.typeId = id
} else {
delete nextQuery.typeId
}
router.replace({ path: route.path, query: nextQuery }).catch(() => {})
})
watch(selectedType, (type) => {
if (!type) {
clearForm()
customFieldInputs.value = []
return
}
if (!creationForm.name) {
creationForm.name = type.name
}
customFieldInputs.value = normalizeCustomFieldInputsFromUtils(normalizeProductStructureForSave(type.structure))
})
const requiredCustomFieldsFilled = computed(() =>
customFieldInputs.value.every((field) => {
if (!field.required) {
return true
}
if (field.type === 'boolean') {
return field.value === 'true' || field.value === 'false'
}
return field.value.trim().length > 0
}),
)
const canSubmit = computed(() => Boolean(
canEdit.value &&
selectedType.value &&
creationForm.name.trim().length >= 2 &&
requiredCustomFieldsFilled.value &&
!submitting.value,
))
const clearForm = () => {
creationForm.name = ''
creationForm.reference = ''
creationForm.constructeurIds = []
creationForm.supplierPrice = ''
}
const buildPayload = () => {
const payload: Record<string, any> = {
name: creationForm.name.trim(),
typeProductId: selectedType.value?.id,
}
const reference = creationForm.reference.trim()
if (reference) {
payload.reference = reference
}
const rawPrice = typeof creationForm.supplierPrice === 'string'
? creationForm.supplierPrice.trim()
: creationForm.supplierPrice
if (rawPrice !== '' && rawPrice !== null && rawPrice !== undefined) {
const parsed = Number(rawPrice)
if (!Number.isNaN(parsed)) {
payload.supplierPrice = String(parsed)
}
}
return payload
}
const submitCreation = async () => {
if (!selectedType.value) {
toast.showError('Sélectionnez une catégorie de produit.')
return
}
submitting.value = true
try {
const payload = buildPayload()
const result = await createProduct(payload)
if (result.success && result.data?.id) {
const productId = result.data.id
const failedFields = await saveCustomFieldValues(result.data.id)
if (failedFields.length) {
toast.showError(`Produit créé, mais impossible d'enregistrer ${failedFields.length} champ(s): ${failedFields.join(', ')}`)
await router.replace(`/product/${result.data.id}?edit=true`)
return
}
// Sync constructeur links after creation
if (constructeurLinks.value.length) {
await syncLinks('product', productId, [], constructeurLinks.value)
}
if (selectedDocuments.value.length) {
uploadingDocuments.value = true
const uploadResult = await uploadDocuments(
{
files: selectedDocuments.value,
context: { productId },
},
{ updateStore: false },
)
if (!uploadResult.success) {
const message = uploadResult.error
? `Documents non ajoutés : ${uploadResult.error}`
: 'Documents non ajoutés : une erreur est survenue.'
toast.showError(message)
} else {
selectedDocuments.value = []
}
}
toast.showSuccess('Produit créé avec succès')
await router.replace(`/product/${productId}?edit=true`)
}
} catch (error: any) {
toast.showError(error?.message || 'Erreur lors de la création du produit')
} finally {
submitting.value = false
uploadingDocuments.value = false
}
}
const saveCustomFieldValues = async (productId: string) => {
const failed: string[] = []
for (const field of customFieldInputs.value) {
if (!field.name) {
continue
}
const value = field.value ?? ''
const metadata = field.customFieldId
? undefined
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
const result = await upsertCustomFieldValue(
field.customFieldId,
'product',
productId,
String(value ?? ''),
metadata,
)
if (!result.success) {
failed.push(field.name)
} else {
const createdValue = result.data
if (createdValue?.id) {
field.customFieldValueId = createdValue.id
}
const resolvedId = createdValue?.customField?.id || field.customFieldId
if (resolvedId) {
field.customFieldId = resolvedId
}
}
}
return failed
}
// Sync constructeurIds → constructeurLinks when IDs are added via ConstructeurSelect
watch(
() => creationForm.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 loadProductTypes()
if (selectedTypeId.value && !selectedType.value) {
await router.replace({
path: route.path,
query: { ...route.query, typeId: undefined },
}).catch(() => {})
}
})
</script>

View File

@@ -0,0 +1,124 @@
<template>
<main class="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
<div class="w-full max-w-md">
<div class="card bg-base-100 shadow-2xl">
<div class="card-body">
<h1 class="text-2xl font-bold mb-6 text-center">
Connexion
</h1>
<div v-if="loadingProfiles" class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg" />
</div>
<form v-else @submit.prevent="handleLogin">
<div class="form-control mb-4">
<label class="label">
<span class="label-text">Profil</span>
</label>
<select
v-model="selectedProfileId"
class="select select-bordered w-full"
required
>
<option value="" disabled>
Choisir un profil...
</option>
<option
v-for="profile in profiles"
:key="profile.id"
:value="profile.id"
>
{{ profile.firstName }} {{ profile.lastName }}
</option>
</select>
</div>
<div class="form-control mb-2">
<label class="label">
<span class="label-text">Mot de passe</span>
</label>
<input
ref="passwordInput"
v-model="password"
type="password"
placeholder="Mot de passe"
class="input input-bordered w-full"
:class="{ 'input-error': loginError }"
>
</div>
<p v-if="loginError" class="text-error text-sm mb-4">
{{ loginError }}
</p>
<button
type="submit"
class="btn btn-primary w-full mt-4"
:disabled="!selectedProfileId || submitting"
>
<span v-if="submitting" class="loading loading-spinner loading-xs" />
Se connecter
</button>
</form>
<footer v-if="activeProfile" class="mt-6 pt-4 border-t border-base-300 flex justify-between items-center">
<div class="text-sm text-base-content/70">
Connecte :
<span class="font-semibold">{{ activeProfile.firstName }} {{ activeProfile.lastName }}</span>
</div>
<button type="button" class="btn btn-outline btn-sm" @click="handleLogout">
Deconnexion
</button>
</footer>
</div>
</div>
</div>
</main>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useProfiles, useProfileSession } from '#imports'
const router = useRouter()
const { profiles, loadingProfiles, fetchProfiles } = useProfiles()
const { activeProfile, activateProfile, fetchCurrentProfile, logout } = useProfileSession()
const selectedProfileId = ref('')
const password = ref('')
const loginError = ref('')
const submitting = ref(false)
const passwordInput = ref(null)
const handleLogin = async () => {
if (!selectedProfileId.value) { return }
submitting.value = true
loginError.value = ''
try {
await activateProfile(selectedProfileId.value, password.value || undefined)
await router.push('/')
} catch (error) {
const err = error
if (err?.status === 401 || err?.statusCode === 401) {
loginError.value = 'Mot de passe incorrect.'
} else {
loginError.value = 'Erreur lors de la connexion.'
}
} finally {
submitting.value = false
}
}
const handleLogout = async () => {
await logout()
selectedProfileId.value = ''
password.value = ''
}
onMounted(async () => {
await fetchProfiles()
await fetchCurrentProfile()
})
</script>

View File

@@ -0,0 +1,119 @@
<template>
<main class="container mx-auto px-6 py-8">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
:documents="siteDocuments"
@close="closePreview"
/>
<div class="my-8">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold">
Sites
</h2>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
Ajouter un site
</button>
</div>
<div v-if="loading" class="flex justify-center items-center py-12">
<span class="loading loading-spinner loading-lg" />
</div>
<div v-else-if="sites.length === 0" class="text-center py-12">
<div class="max-w-md mx-auto">
<IconLucideMapPin class="w-16 h-16 mx-auto text-base-content/30 mb-4" aria-hidden="true" />
<h3 class="text-lg font-medium text-base-content mb-2">
Aucun site trouvé
</h3>
<p class="text-base-content/50 mb-4">
Commencez par ajouter votre premier site.
</p>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateSiteModal">
Ajouter un site
</button>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<SiteCard
v-for="site in sites"
:key="site.id"
:site="site"
@edit="editSite"
@delete="confirmDeleteSite"
/>
</div>
</div>
<SiteCreateModal
:visible="showAddSiteModal"
:site="newSite"
:disabled="!canEdit"
@close="closeCreateModal"
@submit="handleCreateSite"
/>
<SiteEditModal
:visible="showEditSiteModal"
:site-name="siteBeingEdited?.name || ''"
:form="editSiteForm"
:documents="siteDocuments"
:selected-files="selectedFiles"
:uploading-documents="uploadingDocuments"
:can-preview-document="canPreviewDocument"
:document-icon="documentIcon"
:format-size="formatSize"
:disabled="!canEdit"
@close="closeEditModal"
@submit="handleUpdateSite"
@remove-document="handleRemoveSiteDocument"
@download-document="downloadDocument"
@preview-document="openPreview"
@update:selected-files="(files) => (selectedFiles.value = files)"
/>
</main>
</template>
<script setup>
import IconLucideMapPin from '~icons/lucide/map-pin'
import IconLucidePlus from '~icons/lucide/plus'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import SiteCard from '~/components/sites/SiteCard.vue'
import SiteCreateModal from '~/components/sites/SiteCreateModal.vue'
import SiteEditModal from '~/components/sites/SiteEditModal.vue'
import { useSiteManagement } from '~/composables/useSiteManagement'
const { canEdit } = usePermissions()
const {
sites,
loading,
showAddSiteModal,
showEditSiteModal,
siteBeingEdited,
newSite,
editSiteForm,
selectedFiles,
uploadingDocuments,
previewDocument,
previewVisible,
siteDocuments,
documentIcon,
handleCreateSite,
editSite,
handleUpdateSite,
closeEditModal,
handleRemoveSiteDocument,
downloadDocument,
openPreview,
closePreview,
formatSize,
confirmDeleteSite,
canPreviewDocument,
openCreateSiteModal,
closeCreateModal
} = useSiteManagement()
</script>