Compare commits

..

43 Commits

Author SHA1 Message Date
Matthieu
6e105fd070 chore : bump version to v1.9.37
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 10:50:20 +02:00
Matthieu
a0c4597de0 fix(fournisseurs) : ConstructeurSearchFilter utilise EXISTS subquery au lieu de LEFT JOIN
Le LEFT JOIN sur telephones causait une erreur PostgreSQL 'column must appear in GROUP BY' parce que Doctrine sélectionnait aussi les colonnes des téléphones joints. EXISTS subquery corrélée évite la duplication de lignes sans introduire de GROUP BY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:49:43 +02:00
Matthieu
d3f269452c chore : bump version to v1.9.36
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-13 10:46:51 +02:00
gitea-actions
b3fa927e77 chore : bump version to v1.9.35
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-13 08:44:31 +00:00
Matthieu
f71f4c68da feat(fournisseurs) : pagination serveur + search multi-champs (name/email/telephone) + filtre catégorie + tri
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Backend
- Nouveau ConstructeurSearchFilter : LIKE insensible casse sur name/email + LEFT JOIN telephones.numero, accessible via ?search=
- Constructeur entity : ApiFilter ConstructeurSearchFilter, SearchFilter (categories.id exact), OrderFilter (name, email, createdAt)
- paginationMaximumItemsPerPage 200 -> 2000 (pour ConstructeurSelect et MachineDetail qui chargent l'ensemble en cache)

Frontend
- useConstructeurs : nouvelle fonction fetchConstructeursPage({ page, itemsPerPage, search, categoryId, orderField, orderDirection }) renvoyant { items, totalItems, totalPages, currentPage }
- constructeurs.vue : suppression du filtre/tri client, état page/perPage/totalItems/totalPages, watchers sur search/filter/sort qui reset page=1 et rechargent, prop pagination du DataTable câblée, recharge après create/update/delete

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:44:20 +02:00
gitea-actions
905d5c0957 chore : bump version to v1.9.34
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 36s
2026-05-13 08:23:57 +00:00
Matthieu
03a5d05a2c feat(machine) : champs perso machine en badges plus gros dans entete composants et pieces
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Affiche les champs perso machine entre Row 1 (titre/prix) et Row 2 (fournisseur/catalogue) de l'entete ComponentItem et PieceItem.
Badges plus gros (text-sm), visibles en lecture ET en edition. Edition complete reste dans la section depliee.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:23:47 +02:00
gitea-actions
069cc6e153 chore : bump version to v1.9.33
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 38s
2026-05-13 08:02:55 +00:00
Matthieu
daa0cb1e28 feat(fournisseurs) : categories (M2M) + telephones (1-N) + import customer.json
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
- Nouvelles entites ConstructeurCategorie (referentiel M2M) et ConstructeurTelephone (1-N)
- Constructeur : retrait colonne phone, ajout collections telephones/categories, groupes de serialisation constructeur:read/write
- Migration : cree les 3 tables, migre la colonne phone existante vers constructeur_telephone, drop phone
- Commande app:import-fournisseurs (dry-run par defaut, --force) : non destructive, find-or-create par nom, ne touche jamais un ID existant, ajout-seulement pour telephones/categories
- MAJ MCP tools / MachineStructureController / audit subscriber / tests
- Frontend : page constructeurs avec telephones multiples + categories (tableau, filtre, formulaire), composable useConstructeurCategories, composant ConstructeurCategorieSelect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:02:44 +02:00
gitea-actions
b147845401 chore : bump version to v1.9.32
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 37s
2026-05-11 14:52:40 +00:00
Matthieu
b67af56bd1 fix(search-select) : affiche modelValue au mount en mode creatable
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
En mode creatable, modelValue n'est pas dans options donc selectedOption est null.
Le onMounted ecrasait searchTerm a vide apres que le watch immediate l'avait initialise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:52:28 +02:00
gitea-actions
48c5c5bb33 chore : bump version to v1.9.31
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 2m47s
2026-05-11 14:25:24 +00:00
1e2a1dae62 Merge pull request 'feat(custom-fields) : autocomplete des noms + corrections formule de référence auto' (#3) from feat/custom-field-name-autocomplete into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #3
2026-05-11 14:25:14 +00:00
Matthieu
2a8042ba50 fix(custom-fields) : recharge la formule depuis le BE apres save du ModelType
Sur les pages d'edition de categorie composant/piece, ajoute un
loadCategory() apres updateModelType + syncExecute pour que la formule
mise a jour par propagateCustomFieldRename soit refletee dans le form
sans avoir a recharger la page.
2026-05-11 16:22:58 +02:00
Matthieu
bc32648918 fix(custom-fields) : supporte les caracteres accentues dans les placeholders de formule
La regex \w+ ne capturait pas les caracteres accentues (ex. {Diametre}
avec 'è'), le placeholder restait litteral dans la reference auto.
Remplace par [^}]+ avec le flag u/gu cote PHP et JS pour matcher
n'importe quel caractere entre les accolades.
2026-05-11 16:22:52 +02:00
Matthieu
9027917ea2 fix(custom-fields) : propage le renommage d'un champ dans la formule de reference auto 2026-05-11 16:22:28 +02:00
Matthieu
5244698384 fix(custom-fields) : retire le prefixe /api en double dans l'appel API
useApi() prepend deja apiBaseUrl (= /api), donc l'appel doit etre
/custom-fields/names et non /api/custom-fields/names (sinon 404 sur
/api/api/custom-fields/names).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:26:46 +02:00
Matthieu
17ca857cc3 feat(custom-fields) : invalide le cache de suggestions apres save
Apres chaque save reussi de champs perso (machine via
useMachineCustomFieldDefs, ModelType via useEntityTypes), on invalide
le cache useCustomFieldNameSuggestions pour que les noms nouvellement
crees apparaissent dans les futures autocomplete.

Note : le plan mentionnait ModelTypeForm.vue, mais le save reel se
fait dans useEntityTypes (le composant ne fait qu'emit 'submit').

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 15:09:17 +02:00
Matthieu
e6a85a9de4 feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldDefEditor
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:59:12 +02:00
Matthieu
a4ea44675a feat(custom-fields) : ajoute CustomFieldNameInput wrapper
Encapsule SearchSelect en mode creatable, branche useCustomFieldName-
Suggestions, charge la liste au focus. Permet de remplacer un simple
<input v-model='field.name'> par <CustomFieldNameInput v-model='field.name'>
dans les editeurs de champs perso.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:51:15 +02:00
Matthieu
e5d0c690b7 feat(custom-fields) : ajoute composable useCustomFieldNameSuggestions
Cache module-level partage entre toutes les instances. Lazy load au
premier appel a load(). invalidate() permet de forcer un refresh apres
creation/modification d'un champ perso.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:40:21 +02:00
Matthieu
0255d7dda1 feat(search-select) : ajoute prop creatable pour autoriser la saisie libre
En mode creatable=true, le composant emit le texte tape en temps reel
et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte
ne matche aucune option. Mode strict (defaut) inchange. Le composant
emit aussi 'focus' pour permettre au parent de charger les donnees au
premier focus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:32:06 +02:00
Matthieu
dd7ab2b8e7 feat(custom-fields) : ajoute endpoint GET /api/custom-fields/names
Retourne la liste plate des noms de champs perso distincts (table
custom_fields), pour alimenter une autocompletion cote frontend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 14:14:32 +02:00
Matthieu
73c06169f3 docs(custom-fields) : corrige la source de verite (table custom_fields unique)
L'investigation initiale supposait des customFields JSON dans les
skeleton_*_requirements ; en realite SkeletonStructureService traduit
les customFields du payload ModelType en entites CustomField stockees
dans la table custom_fields. Le SQL est donc un simple SELECT DISTINCT.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 13:33:06 +02:00
Matthieu
5e8e7947f0 docs(custom-fields) : design pour autocomplete des noms de champs perso
Spec validee : endpoint backend qui agrege les noms existants (table
custom_fields + JSON dans skeleton requirements), composant
CustomFieldNameInput qui wrap SearchSelect en mode creatable, cache
module-level partage entre toutes les instances, invalidation apres save.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 12:29:07 +02:00
gitea-actions
649f5a8570 chore : bump version to v1.9.30
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 35s
2026-05-06 14:55:03 +00:00
e6ba2259cb Merge pull request 'refactor : simplification globale (vague 1 + 2) + fix visibilité ActorProfileResolver' (#2) from refactor/simplification-globale into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Reviewed-on: #2
2026-05-06 14:54:54 +00:00
Matthieu
27d51ffdb1 fix(toasts) : auto-dismiss des notifications d'erreur apres 8 secondes
Les toasts d'erreur etaient persistants (duration force a 0) et restaient
affiches jusqu'a fermeture manuelle, ce qui pouvait empiler des messages
obsoletes a l'ecran. Aligne le comportement sur les autres types : duree
par defaut 8s (plus que warning a 6s pour laisser le temps de lire). Une
erreur critique peut toujours etre rendue persistante en passant
explicitement showError(msg, 0).
2026-05-06 16:51:08 +02:00
Matthieu
53d4d5768b refactor(doc) : utilise palier comme exemple plus parlant que pompe
Remplace l'exemple "pompe avec position sur la machine" par un palier
de tete vs palier de pied : exemple plus concret et plus universellement
compris pour illustrer la difference entre champs catalogue et champs
contextuels (custom field values).
2026-05-06 16:40:37 +02:00
Matthieu
3ff89d43ed fix(db) : ajoute les FK CASCADE manquantes documents.composantId et machine_component_links.composantId
Les entités Doctrine déclaraient déjà onDelete: CASCADE pour ces deux
relations, mais les contraintes correspondantes étaient absentes en base.
Conséquence : la suppression d'un composant pouvait laisser des documents
ou des links machine orphelins. La migration nettoie les orphelins
existants (avec trace dans audit_logs) puis ajoute les deux FK.
2026-05-06 16:34:26 +02:00
Matthieu
5c55441e6c fix(audit) : visibilité protected pour ActorProfileResolver
AbstractAuditSubscriber déclarait $actorProfileResolver en private readonly
via promoted property. MachineAuditSubscriber surcharge onFlush() et accède
à $this->actorProfileResolver, mais private n'est pas hérité — PHP voyait
null et levait "Call to a member function resolve() on null" sur chaque
flush Doctrine touchant des link entities.

Le passage à protected suit la convention déjà en place dans la classe
(safeGet, normalizeValue, persistAuditLog, etc. sont protected). readonly
préserve l'immutabilité de la dépendance DI.

Ajoute aussi deux tests de régression pour le clone des contextFieldValues
(symétrique au test composant existant) et nettoie deux lignes vides
cosmétiques laissées par le refactor précédent.

- testCloneMachineCopiesPieceContextFieldValues : vérifie que les CFV
  context d'un MachinePieceLink sont bien rattachées au nouveau lien
  après clone.
- testCloneMachineLeavesSourceContextFieldValuesIntact : vérifie que la
  machine source garde ses CFV context après clone (invariant implicite).
2026-05-06 15:30:59 +02:00
Matthieu
e432153083 refactor : simplification globale (vague 1 + 2)
- ActorProfileResolver : service unique partage par AbstractAuditSubscriber, EntityVersionService et ModelTypeCategoryConversionService (3 implementations dupliquees+divergentes)
- corrige un bug latent : EntityVersionService restoraitsans le fallback Security::getUser, loggant actor=null hors session
- machine-clone : clonage des contextFieldValues integre dans cloneComponentLinks/clonePieceLinks, supprime cloneContextFieldValues et son find() en boucle
- helpers extraits : serializeProductSlots (EntityVersionService), updateModelTypeCategory (ModelTypeCategoryConversionService)
- supprime collectCollectionUpdate() vide + ses appels (AbstractAuditSubscriber)
- useMachineDetailData : retire debug ref couplee a isEditMode, componentTypeLabelMap/pieceTypeLabelMap jamais consommes, double assignation machine.productLinks
- PieceItem : retire l'init pieceData dans onMounted (deja couvert par reactive() et le watcher)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-06 10:14:23 +02:00
Matthieu
b16b619fc9 docs : ajoute note delegation Codex pour taches mecaniques 2026-05-06 09:52:08 +02:00
gitea-actions
c88333b052 chore : bump version to v1.9.29
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m6s
2026-05-03 18:05:16 +00:00
8f5cd98b82 fix(machine-clone) : preserve context field values when cloning a machine
All checks were successful
Auto Tag Develop / tag (push) Successful in 35s
Context CustomFieldValues attached to component/piece links were
silently dropped from the clone response (and from any subsequent
read in the same request) because the controller persisted the new
CFVs without adding them to the inverse-side collection of the new
link. Doctrine does not auto-sync inverse OneToMany associations,
so getContextFieldValues() returned an empty collection on the
freshly persisted link.

Also synchronise the inverse collection in the test factory so
identity-mapped entities reflect newly-created CFVs when reused
by request handlers within the same test.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:59:03 +02:00
48f7e4c6ac test(session) : align expectations with hardened auth from WIP 476060c
Generic 'Identifiants invalides.' is now returned for both wrong
password and missing-password-set cases (security obscurity, prevents
account enumeration). Tests still asserted the granular 'Mot de passe
incorrect.' message and a 403 status that the controller no longer
emits.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:56:53 +02:00
c46769a67d fix(model-types) : nullify weak references on ModelType delete
Belt-and-suspenders against orphan refs when a ModelType is deleted:
applicatively nullifies typeComposantId / typePieceId / typeProductId
on every "ON DELETE SET NULL" relationship before the row is removed,
in case the database FK cascade fails to fire.

Observed in prod 2026-04-28: deletion of ModelType "Paliers" left an
orphan in skeleton_subcomponent_requirements, surfacing as a 500 when
API Platform tried to lazy-load the missing proxy.

Co-Authored-By: RuFlo <ruv@ruv.net>
2026-05-03 19:29:36 +02:00
gitea-actions
28394ce1b4 chore : bump version to v1.9.28
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 3m16s
2026-04-10 14:57:59 +00:00
Matthieu
8cfcb41a39 feat(conversion) : commande CLI pour convertir la catégorie Moteur de PIECE vers COMPONENT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Migre les 18 pièces en composants, transfère documents, custom fields,
slots et skeleton requirements dans une transaction. Supporte --dry-run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:57:46 +02:00
gitea-actions
980a7c310e chore : bump version to v1.9.27
All checks were successful
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 2m32s
2026-04-09 12:34:46 +00:00
Matthieu
00f18d1c7d feat(infra) : add monolog logging and persist logs in prod
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
Install symfony/monolog-bundle with rotating_file handlers.
Add named volume inventory_logs for prod log persistence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 14:33:42 +02:00
gitea-actions
6e2c5179a9 chore : bump version to v1.9.26
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 34s
2026-04-06 18:46:40 +00:00
3cd18a721a feat(ui) : refonte cartes dépliantes structure machine + DataTable parc machines + fix activity-log
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
- Parc Machines transformé en DataTable avec filtres (site, date création, recherche)
- Vue d'ensemble : ajout filtre par plage de dates de création
- Activity-log : correction des liens entités (routes singulier sans /edit, ajout machine/document/model_type)
- ComponentItem & PieceItem : refonte complète des cartes dépliantes (design industriel raffiné)
  - Header compact avec tags colorés contrastés (référence, réf. auto, prix, produit, champs machine)
  - Panneau déplié structuré en sections avec mini-headers
  - Bordure gauche primary pour hiérarchie visuelle
- Ajout referenceAuto dans header et infos pour composants et pièces
- Suppression double encadrement ComponentHierarchy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:46:25 +02:00
79 changed files with 5050 additions and 986 deletions

View File

@@ -7,6 +7,13 @@
"X-Profile-Id": "admin-default-profile", "X-Profile-Id": "admin-default-profile",
"X-Profile-Password": "A123" "X-Profile-Password": "A123"
} }
},
"lesstime": {
"type": "http",
"url": "http://project.malio-dev.fr/_mcp",
"headers": {
"Authorization": "Bearer b355c6cbf27d2a86d7eba1c3132c99bb3133f94cfd9e9243ffcc3c5ae1dc82c8"
}
} }
} }
} }

View File

@@ -81,6 +81,11 @@ make fixtures-reset # Reset DB + recharger fixtures
make import-data # Importer les dumps SQL normalisés make import-data # Importer les dumps SQL normalisés
make cache-clear # Clear cache Symfony make cache-clear # Clear cache Symfony
# Import fournisseurs (customer.json → Constructeur + ConstructeurCategorie + ConstructeurTelephone)
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs # dry-run (par défaut)
docker exec -u www-data php-inventory-apache php bin/console app:import-fournisseurs --force # applique
# Non destructif : find-or-create par nom normalisé, ne change jamais un ID existant, n'ajoute que les téléphones/catégories manquants
# Release # Release
./scripts/release.sh patch # Bump patch version (ou minor/major) ./scripts/release.sh patch # Bump patch version (ou minor/major)
``` ```
@@ -116,7 +121,9 @@ Le frontend est un submodule git. Lors d'un commit frontend :
## Architecture Backend ## Architecture Backend
### Entités Principales ### Entités Principales
`Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink` `Machine`, `Piece`, `Composant`, `Product`, `Constructeur`, `ConstructeurCategorie`, `ConstructeurTelephone`, `Site`, `ModelType`, `CustomField`, `CustomFieldValue`, `Document`, `AuditLog`, `Comment`, `Profile`, `MachineComponentLink`, `MachinePieceLink`, `MachineProductLink`
> **Constructeur (Fournisseur)** : possède `name`, `email`, une collection `telephones` (1-N → `ConstructeurTelephone`, cascade/orphanRemoval) et `categories` (M2M → `ConstructeurCategorie`, table `constructeur_categories`). Sérialisation API Platform via les groupes `constructeur:read` / `constructeur:write` (téléphones & catégories embarqués). ⚠️ L'adder M2M s'appelle `addCategory()`/`removeCategory()` (l'inflector singularise `categories` → `category`), pas `addCategorie`. `ConstructeurCategorie` et `ConstructeurTelephone` sont aussi des `ApiResource` à part entière (`/api/constructeur_categories`, `/api/constructeur_telephones`).
#### Entités de normalisation (slots & skeleton requirements) #### Entités de normalisation (slots & skeleton requirements)
Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles : Remplacent les anciennes colonnes JSON `structure` et `productIds` par des tables relationnelles :
@@ -199,6 +206,7 @@ ROLE_ADMIN → ROLE_GESTIONNAIRE → ROLE_VIEWER → ROLE_USER
- **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)` - **Composables** : `interface Deps { ... }` + `export function useXxx(deps: Deps)`
- **Communication composants** : Props + Events uniquement (pas de provide/inject) - **Communication composants** : Props + Events uniquement (pas de provide/inject)
- **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session - **API** : `useApi.ts` wraps fetch avec `credentials: 'include'` pour les cookies session
- **⚠️ Préfixe `/api`** : `useApi()` **prepend déjà** `apiBaseUrl` (= `/api` par défaut, cf. `nuxt.config.ts`). Les appels doivent donc utiliser des chemins **sans** `/api` au début. Ex : `api.get('/custom-fields/names')` et **PAS** `api.get('/api/custom-fields/names')` (sinon 404 sur `/api/api/...`).
- **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH - **Content-Type** : `application/ld+json` pour POST/PUT, `application/merge-patch+json` pour PATCH
- **Auth** : `useProfileSession` + middleware global `profile.global.ts` - **Auth** : `useProfileSession` + middleware global `profile.global.ts`
- **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client - **Permissions** : `usePermissions.ts` miroir de la hiérarchie backend côté client
@@ -256,7 +264,7 @@ make test-setup # Créer/mettre à jour le schéma test
### Pattern de test ### Pattern de test
- Hériter de `AbstractApiTestCase` (helpers auth + factories) - Hériter de `AbstractApiTestCase` (helpers auth + factories)
- Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback - Ne PAS faire de TRUNCATE/cleanup dans tearDown — DAMA s'en occupe par rollback
- Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()` - Factories : `createProfile()`, `createMachine()`, `createSite()`, `createComposant()`, `createPiece()`, `createProduct()`, `createConstructeur()`, `createCustomField()`, `createCustomFieldValue()`, `createModelType()`, `createMachineComponentLink()`, `createMachinePieceLink()`, `createMachineProductLink()`, `createComposantPieceSlot()`, `createComposantSubcomponentSlot()`, `createComposantProductSlot()`, `createPieceProductSlot()`, `createConstructeurCategorie()`, `createConstructeurTelephone()`
- Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()` - Auth : `createViewerClient()`, `createGestionnaireClient()`, `createAdminClient()`, `createUnauthenticatedClient()`
## URLs Locales ## URLs Locales
@@ -264,3 +272,12 @@ make test-setup # Créer/mettre à jour le schéma test
- Nuxt dev : `http://localhost:3001` - Nuxt dev : `http://localhost:3001`
- Adminer (PG) : `http://localhost:5050` - Adminer (PG) : `http://localhost:5050`
- PG direct : `localhost:5433` (user: root, pass: root, db: inventory) - PG direct : `localhost:5433` (user: root, pass: root, db: inventory)
## Delegation Codex
Pour les taches mecaniques (tests, boilerplate, renommages, refacto repetitif), delegue a Codex via le plugin `codex`. Garde Claude pour la reflexion, l'architecture et la verification.
- **Codex** = junior dev rapide et pas cher (executions mecaniques)
- **Claude** = senior dev qui verifie et reflechit (design, review, decisions)
C'est le meilleur ratio qualite/credits.

View File

@@ -24,6 +24,7 @@
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
"symfony/mcp-bundle": "^0.6.0", "symfony/mcp-bundle": "^0.6.0",
"symfony/mime": "8.0.*", "symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^4.0.2",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
"symfony/property-info": "8.0.*", "symfony/property-info": "8.0.*",
"symfony/rate-limiter": "8.0.*", "symfony/rate-limiter": "8.0.*",

261
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "f94dc3c05e9ba6be99c510aad3d17182", "content-hash": "5c54b1589d9e815f4c9b7e5e1d2d69c7",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -2437,6 +2437,109 @@
}, },
"time": "2026-02-23T21:42:54+00:00" "time": "2026-02-23T21:42:54+00:00"
}, },
{
"name": "monolog/monolog",
"version": "3.10.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
"reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
"shasum": ""
},
"require": {
"php": ">=8.1",
"psr/log": "^2.0 || ^3.0"
},
"provide": {
"psr/log-implementation": "3.0.0"
},
"require-dev": {
"aws/aws-sdk-php": "^3.0",
"doctrine/couchdb": "~1.0@dev",
"elasticsearch/elasticsearch": "^7 || ^8",
"ext-json": "*",
"graylog2/gelf-php": "^1.4.2 || ^2.0",
"guzzlehttp/guzzle": "^7.4.5",
"guzzlehttp/psr7": "^2.2",
"mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"php-console/php-console": "^3.1.8",
"phpstan/phpstan": "^2",
"phpstan/phpstan-deprecation-rules": "^2",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "^10.5.17 || ^11.0.7",
"predis/predis": "^1.1 || ^2",
"rollbar/rollbar": "^4.0",
"ruflin/elastica": "^7 || ^8",
"symfony/mailer": "^5.4 || ^6",
"symfony/mime": "^5.4 || ^6"
},
"suggest": {
"aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
"doctrine/couchdb": "Allow sending log messages to a CouchDB server",
"elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
"ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
"ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
"ext-mbstring": "Allow to work properly with unicode symbols",
"ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
"ext-openssl": "Required to send log messages using SSL",
"ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
"graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
"mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
"php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
"rollbar/rollbar": "Allow sending log messages to Rollbar",
"ruflin/elastica": "Allow sending log messages to an Elastic Search server"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Monolog\\": "src/Monolog"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "https://seld.be"
}
],
"description": "Sends your logs to files, sockets, inboxes, databases and various web services",
"homepage": "https://github.com/Seldaek/monolog",
"keywords": [
"log",
"logging",
"psr-3"
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
"source": "https://github.com/Seldaek/monolog/tree/3.10.0"
},
"funding": [
{
"url": "https://github.com/Seldaek",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
"type": "tidelift"
}
],
"time": "2026-01-02T08:56:05+00:00"
},
{ {
"name": "nelmio/cors-bundle", "name": "nelmio/cors-bundle",
"version": "2.6.0", "version": "2.6.0",
@@ -5427,6 +5530,162 @@
], ],
"time": "2026-03-30T15:14:47+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{
"name": "symfony/monolog-bridge",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bridge.git",
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bridge/zipball/c6efdcbd5cc17cf7618fb4447053b792df6ae724",
"reference": "c6efdcbd5cc17cf7618fb4447053b792df6ae724",
"shasum": ""
},
"require": {
"monolog/monolog": "^3",
"php": ">=8.4",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/service-contracts": "^2.5|^3"
},
"require-dev": {
"symfony/console": "^7.4|^8.0",
"symfony/http-client": "^7.4|^8.0",
"symfony/mailer": "^7.4|^8.0",
"symfony/messenger": "^7.4|^8.0",
"symfony/mime": "^7.4|^8.0",
"symfony/security-core": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\Monolog\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides integration for Monolog with various Symfony components",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/monolog-bridge/tree/v8.0.8"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/monolog-bundle",
"version": "v4.0.2",
"source": {
"type": "git",
"url": "https://github.com/symfony/monolog-bundle.git",
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/monolog-bundle/zipball/c012c6aba13129eb02aa7dd61e66e720911d8598",
"reference": "c012c6aba13129eb02aa7dd61e66e720911d8598",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.0",
"monolog/monolog": "^3.5",
"php": ">=8.2",
"symfony/config": "^7.3 || ^8.0",
"symfony/dependency-injection": "^7.3 || ^8.0",
"symfony/http-kernel": "^7.3 || ^8.0",
"symfony/monolog-bridge": "^7.3 || ^8.0",
"symfony/polyfill-php84": "^1.30"
},
"require-dev": {
"phpunit/phpunit": "^11.5.41 || ^12.3",
"symfony/console": "^7.3 || ^8.0",
"symfony/yaml": "^7.3 || ^8.0"
},
"type": "symfony-bundle",
"autoload": {
"psr-4": {
"Symfony\\Bundle\\MonologBundle\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony MonologBundle",
"homepage": "https://symfony.com",
"keywords": [
"log",
"logging"
],
"support": {
"issues": "https://github.com/symfony/monolog-bundle/issues",
"source": "https://github.com/symfony/monolog-bundle/tree/v4.0.2"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-02T18:27:21+00:00"
},
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v8.0.0", "version": "v8.0.0",

View File

@@ -9,6 +9,7 @@ use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
use Nelmio\CorsBundle\NelmioCorsBundle; use Nelmio\CorsBundle\NelmioCorsBundle;
use Symfony\AI\McpBundle\McpBundle; use Symfony\AI\McpBundle\McpBundle;
use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
use Symfony\Bundle\MonologBundle\MonologBundle;
use Symfony\Bundle\SecurityBundle\SecurityBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle;
use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Bundle\TwigBundle\TwigBundle;
@@ -22,4 +23,5 @@ return [
ApiPlatformBundle::class => ['all' => true], ApiPlatformBundle::class => ['all' => true],
DAMADoctrineTestBundle::class => ['test' => true], DAMADoctrineTestBundle::class => ['test' => true],
McpBundle::class => ['all' => true], McpBundle::class => ['all' => true],
MonologBundle::class => ['all' => true],
]; ];

View File

@@ -0,0 +1,56 @@
monolog:
channels:
- deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists
when@dev:
monolog:
handlers:
main:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 7
channels: ["!event"]
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine", "!console"]
when@test:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!event"]
nested:
type: stream
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
when@prod:
monolog:
handlers:
main:
type: fingers_crossed
action_level: error
handler: nested
excluded_http_codes: [404, 405]
channels: ["!deprecation"]
buffer_size: 50
nested:
type: rotating_file
path: "%kernel.logs_dir%/%kernel.environment%.log"
level: debug
max_files: 30
console:
type: console
process_psr_3_messages: false
channels: ["!event", "!doctrine"]
deprecation:
type: rotating_file
channels: [deprecation]
path: "%kernel.logs_dir%/deprecations.log"
max_files: 7

View File

@@ -69,3 +69,8 @@ when@test:
autowire: true autowire: true
autoconfigure: true autoconfigure: true
public: true public: true
App\Service\SkeletonStructureService:
autowire: true
autoconfigure: true
public: true

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '1.9.25' app.version: '1.9.37'

View File

@@ -0,0 +1,926 @@
# Custom Field Name Autocomplete — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ajouter une autocomplétion sur les noms de champs personnalisés dans tous les éditeurs (machine + ModelType) pour permettre la réutilisation des noms existants tout en gardant la possibilité d'en créer de nouveaux.
**Architecture:**
- **Backend** : un endpoint utilitaire `GET /api/custom-fields/names` qui retourne la liste plate des noms distincts de la table `custom_fields`.
- **Frontend** : extension de `SearchSelect.vue` avec un prop `creatable`, composable `useCustomFieldNameSuggestions` avec cache module-level, composant wrapper `CustomFieldNameInput.vue`, migration de 4 éditeurs.
**Tech Stack:** Symfony 8 + API Platform + Doctrine DBAL, Nuxt 4 + Vue 3 Composition API + TypeScript, DaisyUI.
**Référence spec:** `docs/superpowers/specs/2026-05-11-custom-field-name-autocomplete-design.md`
---
## Task 1: Backend — Controller `CustomFieldNamesController`
**Files:**
- Create: `src/Controller/CustomFieldNamesController.php`
- [ ] **Step 1: Créer le controller**
Créer `src/Controller/CustomFieldNamesController.php` avec le contenu :
```php
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
final class CustomFieldNamesController
{
public function __construct(private readonly Connection $connection)
{
}
#[Route(
path: '/api/custom-fields/names',
name: 'api_custom_fields_names',
methods: ['GET']
)]
#[IsGranted('ROLE_VIEWER')]
public function __invoke(): JsonResponse
{
$sql = <<<'SQL'
SELECT DISTINCT name
FROM custom_fields
WHERE name IS NOT NULL AND name <> ''
ORDER BY name ASC
SQL;
$names = $this->connection->fetchFirstColumn($sql);
return new JsonResponse($names);
}
}
```
- [ ] **Step 2: Vérifier que la route est bien exposée**
Exécuter :
```bash
docker exec -u www-data php-inventory-apache php bin/console debug:router | grep custom-fields/names
```
Attendu : une ligne contenant `GET /api/custom-fields/names` et `api_custom_fields_names`.
- [ ] **Step 3: Tester manuellement le endpoint**
```bash
curl -s -b "$(curl -s -c - -X POST http://localhost:8081/api/session/profile \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin"}' | grep PHPSESSID)" \
http://localhost:8081/api/custom-fields/names | head -c 500
```
> Si la session est galère à monter en curl, on peut tester via le navigateur après login (DevTools → fetch).
Attendu : un tableau JSON `["Numéro de série", "Tension", ...]` (ou `[]` si la base de dev est vide).
- [ ] **Step 4: Lancer php-cs-fixer**
```bash
make php-cs-fixer-allow-risky
```
- [ ] **Step 5: Commit**
```bash
git add src/Controller/CustomFieldNamesController.php
git commit -m "feat(custom-fields) : ajoute endpoint GET /api/custom-fields/names
Retourne la liste plate des noms de champs perso distincts (table
custom_fields), pour alimenter une autocompletion cote frontend."
```
> ⚠️ Le pre-commit hook va lancer PHPUnit. Si des tests existants échouent (peu probable car on n'a touché à rien d'existant), résoudre le souci avant de continuer.
---
## Task 2: Backend — Test PHPUnit du endpoint
**Files:**
- Create: `tests/Api/Controller/CustomFieldNamesControllerTest.php`
- [ ] **Step 1: Repérer un test existant à copier-coller pour le style**
Lire `tests/Api/Controller/HealthCheckController*Test.php` ou un controller simple existant pour récupérer le pattern (auth helpers, `ApiTestCase`). Adapter selon ce qu'on trouve.
```bash
ls tests/Api/Controller/ | head
```
- [ ] **Step 2: Créer le test**
Créer `tests/Api/Controller/CustomFieldNamesControllerTest.php` :
```php
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
final class CustomFieldNamesControllerTest extends AbstractApiTestCase
{
public function testReturns401WhenUnauthenticated(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/custom-fields/names');
self::assertResponseStatusCodeSame(401);
}
public function testReturnsEmptyArrayWhenNoCustomFields(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/custom-fields/names');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertIsArray($data);
}
public function testReturnsDistinctSortedNames(): void
{
// Crée 3 machines avec des CustomField : "Tension", "Numéro de série", "Tension" (doublon)
$machine1 = $this->createMachine();
$this->createCustomField(['name' => 'Tension', 'machine' => $machine1]);
$this->createCustomField(['name' => 'Numéro de série', 'machine' => $machine1]);
$machine2 = $this->createMachine();
$this->createCustomField(['name' => 'Tension', 'machine' => $machine2]); // doublon
$client = $this->createViewerClient();
$client->request('GET', '/api/custom-fields/names');
self::assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
self::assertContains('Tension', $data);
self::assertContains('Numéro de série', $data);
// Pas de doublon
self::assertSame(count(array_unique($data)), count($data));
// Tri alpha
$sorted = $data;
sort($sorted, SORT_STRING);
self::assertSame($sorted, $data);
}
}
```
> Si la factory `createCustomField` n'a pas la signature attendue (1er argument = array), regarder `tests/AbstractApiTestCase.php` pour adapter aux helpers réels du projet.
- [ ] **Step 3: Vérifier que les helpers utilisés existent**
```bash
grep -n "createCustomField\|createMachine\|createViewerClient\|createUnauthenticatedClient" tests/AbstractApiTestCase.php | head
```
Si l'un des helpers manque ou a une autre signature, **adapter le test** plutôt que d'ajouter de nouveaux helpers.
- [ ] **Step 4: Lancer le test ciblé**
```bash
make test FILES=tests/Api/Controller/CustomFieldNamesControllerTest.php
```
Attendu : 3 tests OK.
- [ ] **Step 5: Commit**
```bash
git add tests/Api/Controller/CustomFieldNamesControllerTest.php
git commit -m "test(custom-fields) : ajoute test PHPUnit pour endpoint /api/custom-fields/names"
```
---
## Task 3: Frontend — Étendre `SearchSelect.vue` avec le prop `creatable`
**Files:**
- Modify: `frontend/app/components/common/SearchSelect.vue`
- [ ] **Step 1: Ajouter le prop `creatable` au composant**
Dans le bloc `defineProps` (lignes ~91-141), ajouter après le prop `serverSearch` :
```js
creatable: {
type: Boolean,
default: false
}
```
- [ ] **Step 2: Modifier `handleInput` pour emit en mode creatable**
Remplacer la fonction `handleInput` (lignes ~284-289) par :
```js
function handleInput () {
if (!openDropdown.value) {
openDropdown.value = true
}
if (props.creatable) {
emit('update:modelValue', searchTerm.value)
}
emit('search', searchTerm.value)
}
```
- [ ] **Step 3: Modifier `closeDropdown` pour ne pas reset en mode creatable**
Remplacer la fonction `closeDropdown` (lignes ~297-304) par :
```js
function closeDropdown () {
openDropdown.value = false
if (props.creatable) {
return // garde le texte tapé tel quel
}
if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '')
} else if (selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
}
```
- [ ] **Step 4: Ajouter une computed `creatableSuggestion`**
Dans le bloc `<script setup>`, après la `computed displayedOptions` (ligne ~173), ajouter :
```js
const creatableSuggestion = computed(() => {
if (!props.creatable) return null
const term = searchTerm.value.trim()
if (!term) return null
// Affiche "Créer ..." uniquement si aucune option exacte ne matche (case-insensitive)
const exists = baseOptions.value.some(option => {
const label = resolveLabel(option).toLowerCase()
return label === term.toLowerCase()
})
return exists ? null : term
})
```
- [ ] **Step 5: Afficher la ligne "Créer ..." dans le template**
Localiser le bloc dropdown (lignes ~40-81). Juste **après** la `<ul>` qui contient les options (donc juste avant la fermeture du `<div v-if="openDropdown">`), ajouter :
```vue
<button
v-if="creatableSuggestion"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
@click="confirmCreatable"
>
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
Créer « {{ creatableSuggestion }} »
</button>
```
Ajouter l'import en haut :
```js
import IconLucidePlus from '~icons/lucide/plus'
```
- [ ] **Step 6: Ajouter la fonction `confirmCreatable`**
Après `clearSelection` (ligne ~295), ajouter :
```js
function confirmCreatable () {
if (creatableSuggestion.value) {
emit('update:modelValue', creatableSuggestion.value)
}
openDropdown.value = false
}
```
- [ ] **Step 7: Ajuster la sync `searchTerm` ↔ `modelValue` en mode creatable**
Localiser le `watch` sur `modelValue` (lignes ~194-202). Remplacer par :
```js
watch(
() => props.modelValue,
() => {
if (props.creatable) {
if (searchTerm.value !== props.modelValue) {
searchTerm.value = String(props.modelValue ?? '')
}
return
}
if (!openDropdown.value) {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
},
{ immediate: true }
)
```
> En mode creatable, `modelValue` et `searchTerm` reflètent la même chose (le texte tapé) — on évite juste la boucle infinie en testant l'égalité.
- [ ] **Step 8: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors.
- [ ] **Step 9: Lancer ESLint**
```bash
cd frontend && npm run lint:fix
```
- [ ] **Step 10: Test de non-régression manuel**
Vérifier qu'un usage existant de `SearchSelect` (par exemple sur la page `frontend/app/pages/index.vue` ou similaire — chercher `<SearchSelect`) fonctionne toujours **sans** le prop `creatable` : le comportement strict doit être identique à avant.
```bash
cd frontend && grep -rln "SearchSelect" app/ | head -5
```
Ouvrir un de ces écrans en dev et vérifier que :
- La sélection d'une option marche
- Le blur sans sélection reset au label précédent (= mode strict inchangé)
- [ ] **Step 11: Commit**
```bash
git add frontend/app/components/common/SearchSelect.vue
git commit -m "feat(search-select) : ajoute prop creatable pour autoriser la saisie libre
En mode creatable=true, le composant emit le texte tape en temps reel
et ne reset plus au blur. Une ligne 'Creer XYZ' apparait quand le texte
ne matche aucune option. Mode strict (defaut) inchange."
```
---
## Task 4: Frontend — Composable `useCustomFieldNameSuggestions`
**Files:**
- Create: `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- [ ] **Step 1: Vérifier le pattern `useApi` existant**
```bash
cat frontend/app/composables/useApi.ts | head -30
```
Noter la signature exacte (`<T>(path, opts?) => Promise<T>` ou autre) pour l'adapter.
- [ ] **Step 2: Créer le composable**
Créer `frontend/app/composables/useCustomFieldNameSuggestions.ts` :
```ts
import { ref } from 'vue'
const cache = ref<string[] | null>(null)
const loading = ref(false)
interface Deps {
api: ReturnType<typeof useApi>
}
export function useCustomFieldNameSuggestions(deps: Deps) {
const { api } = deps
async function load(force = false): Promise<string[]> {
if (cache.value && !force) return cache.value
if (loading.value) return cache.value ?? []
loading.value = true
try {
const result = await api<string[]>('/api/custom-fields/names')
cache.value = Array.isArray(result) ? result : []
return cache.value
} catch (err) {
console.error('[useCustomFieldNameSuggestions] failed to load', err)
cache.value = cache.value ?? []
return cache.value
} finally {
loading.value = false
}
}
function invalidate(): void {
cache.value = null
}
return {
suggestions: cache,
loading,
load,
invalidate,
}
}
```
> Note : `cache` et `loading` sont déclarés **au niveau du module** (hors de la fonction) → cache partagé entre toutes les instances.
- [ ] **Step 3: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors. Si le `ReturnType<typeof useApi>` pose souci (selon comment `useApi` est typé), remplacer par un type explicite plus simple :
```ts
interface Deps {
api: <T>(path: string, opts?: RequestInit) => Promise<T>
}
```
- [ ] **Step 4: Commit**
```bash
git add frontend/app/composables/useCustomFieldNameSuggestions.ts
git commit -m "feat(custom-fields) : ajoute composable useCustomFieldNameSuggestions
Cache module-level partage entre toutes les instances. Lazy load au
premier appel a load(). invalidate() permet de forcer un refresh apres
creation/modification d'un champ perso."
```
---
## Task 5: Frontend — Composant wrapper `CustomFieldNameInput`
**Files:**
- Create: `frontend/app/components/common/CustomFieldNameInput.vue`
- [ ] **Step 1: Créer le composant**
Créer `frontend/app/components/common/CustomFieldNameInput.vue` :
```vue
<template>
<SearchSelect
:model-value="modelValue"
:options="options"
:placeholder="placeholder"
option-value="name"
option-label="name"
creatable
:size="size"
@update:model-value="onUpdate"
@focus="ensureLoaded"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchSelect from './SearchSelect.vue'
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
const props = withDefaults(defineProps<{
modelValue: string
placeholder?: string
size?: 'xs' | 'sm' | 'md' | 'lg'
}>(), {
placeholder: 'Nom du champ',
size: 'xs',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
function ensureLoaded(): void {
void load()
}
function onUpdate(value: string | number): void {
emit('update:modelValue', String(value ?? ''))
}
</script>
```
> `SearchSelect` n'expose pas nativement un événement `@focus`. Vérifier dans la Task 3 si on l'a ajouté ou si on doit charger autrement.
- [ ] **Step 2: Exposer `@focus` depuis `SearchSelect.vue`**
Retourner sur `SearchSelect.vue` et vérifier si `@focus` est propagé. Si non, modifier le handler `handleFocus` (ligne ~267-272) pour également émettre :
```js
const emit = defineEmits(['update:modelValue', 'search', 'focus'])
function handleFocus () {
openDropdown.value = true
if (searchTerm.value === '' && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value)
}
emit('focus')
}
```
Ajouter `'focus'` à la liste des emits si pas déjà présent.
- [ ] **Step 3: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 4: Vérifier l'auto-import Nuxt**
Le composant étant dans `components/common/`, Nuxt devrait l'auto-importer. Vérifier après build :
```bash
cd frontend && npm run dev
```
Sans erreur de référence `CustomFieldNameInput is not defined` quand on l'utilisera dans les tâches suivantes.
- [ ] **Step 5: Commit**
```bash
git add frontend/app/components/common/CustomFieldNameInput.vue frontend/app/components/common/SearchSelect.vue
git commit -m "feat(custom-fields) : ajoute CustomFieldNameInput wrapper
Encapsule SearchSelect en mode creatable, branche useCustomFieldName-
Suggestions, charge la liste au focus. Permet de remplacer un simple
<input v-model='field.name'> par <CustomFieldNameInput v-model='field.name'>
dans les editeurs de champs perso."
```
---
## Task 6: Frontend — Migrer `MachineCustomFieldDefEditor.vue`
**Files:**
- Modify: `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
- [ ] **Step 1: Remplacer l'input du nom**
Dans `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`, localiser les lignes 36-41 :
```vue
<input
v-model="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="sm"
/>
```
- [ ] **Step 2: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 3: Test manuel rapide**
Ouvrir une machine en édition, ajouter un champ perso, vérifier que l'input :
1. Affiche un dropdown au focus avec les noms existants
2. Filtre quand on tape
3. Sélection d'une suggestion → input rempli
4. Texte libre + blur → garde le texte tapé
- [ ] **Step 4: Commit**
```bash
git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldDefEditor"
```
---
## Task 7: Frontend — Migrer `MachineCustomFieldsCard.vue`
**Files:**
- Modify: `frontend/app/components/machine/MachineCustomFieldsCard.vue`
- [ ] **Step 1: Remplacer l'input du nom**
Dans `frontend/app/components/machine/MachineCustomFieldsCard.vue`, localiser les lignes ~53-59 :
```vue
<input
:value="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ"
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
/>
```
⚠️ **Attention** : cet input utilise `:value` + `@blur` (pas `v-model`) parce qu'il déclenche une mise à jour seulement au blur (avec un appel API).
Remplacer par :
```vue
<CustomFieldNameInput
:model-value="field.name"
placeholder="Nom du champ"
size="sm"
@update:model-value="(value) => handleDefinitionUpdate(field, 'name', value)"
/>
```
> Le `@update:model-value` se déclenchera à chaque changement (donc à chaque caractère tapé en mode creatable). Si ce comportement génère trop d'appels API, on peut wrapper avec un `debounce` côté `handleDefinitionUpdate`. Pour l'instant, on garde simple.
- [ ] **Step 2: Vérifier le comportement de `handleDefinitionUpdate`**
Vérifier que cette fonction est idempotente (rejouer un même nom = pas d'effet). Cherche la fonction dans le composant et confirme qu'elle compare l'ancienne/nouvelle valeur avant d'appeler l'API.
```bash
grep -n "handleDefinitionUpdate" frontend/app/components/machine/MachineCustomFieldsCard.vue
```
Si elle ne dédoublonne pas, l'ajout d'un test rapide `if (field.name === value) return` peut éviter des PATCH inutiles.
- [ ] **Step 3: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 4: Test manuel**
Sur la page d'une machine, modifier inline un nom de champ perso → vérifier que ça déclenche un PATCH unique (DevTools Network).
- [ ] **Step 5: Commit**
```bash
git add frontend/app/components/machine/MachineCustomFieldsCard.vue
git commit -m "feat(custom-fields) : autocomplete sur le nom dans MachineCustomFieldsCard"
```
---
## Task 8: Frontend — Migrer `PieceModelStructureEditor.vue`
**Files:**
- Modify: `frontend/app/components/PieceModelStructureEditor.vue`
- [ ] **Step 1: Remplacer l'input du nom**
Dans `frontend/app/components/PieceModelStructureEditor.vue`, localiser les lignes 97-102 :
```vue
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="xs"
/>
```
- [ ] **Step 2: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 3: Test manuel**
Ouvrir un ModelType (catégorie composant ou skeleton), ajouter une pièce dans le skeleton, lui ajouter un champ perso → vérifier dropdown.
- [ ] **Step 4: Commit**
```bash
git add frontend/app/components/PieceModelStructureEditor.vue
git commit -m "feat(custom-fields) : autocomplete sur le nom dans PieceModelStructureEditor"
```
---
## Task 9: Frontend — Migrer `StructureNodeEditor.vue`
**Files:**
- Modify: `frontend/app/components/StructureNodeEditor.vue`
- [ ] **Step 1: Remplacer l'input du nom**
Dans `frontend/app/components/StructureNodeEditor.vue`, localiser les lignes 106-111 :
```vue
<input
v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ"
/>
```
Remplacer par :
```vue
<CustomFieldNameInput
v-model="field.name"
placeholder="Nom du champ"
size="xs"
/>
```
- [ ] **Step 2: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 3: Test manuel**
Ouvrir un ModelType de catégorie machine, naviguer dans la structure (composants/sous-composants), ajouter un champ perso à un node → vérifier dropdown.
- [ ] **Step 4: Commit**
```bash
git add frontend/app/components/StructureNodeEditor.vue
git commit -m "feat(custom-fields) : autocomplete sur le nom dans StructureNodeEditor"
```
---
## Task 10: Frontend — Invalidation du cache après save
**Files:**
- Modify: `frontend/app/composables/useMachineCustomFieldDefs.ts`
- Modify: `frontend/app/components/model-types/ModelTypeForm.vue`
- [ ] **Step 1: Repérer le save dans `useMachineCustomFieldDefs.ts`**
```bash
grep -n "POST\|PATCH\|api(" frontend/app/composables/useMachineCustomFieldDefs.ts | head -20
```
Localiser la(es) fonction(s) qui sauvegarde les champs perso (probablement `saveDefinitions`, `addCustomFields`, etc.).
- [ ] **Step 2: Ajouter l'invalidation après save**
Dans `useMachineCustomFieldDefs.ts`, en haut du fichier, après les imports existants :
```ts
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
```
Dans le corps du composable, ajouter (à placer près des autres `use*` calls) :
```ts
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
```
Puis, après chaque save réussi (à la fin du `try` du POST/PATCH des definitions), appeler :
```ts
invalidateCustomFieldNames()
```
> Identifier précisément les points de save dans le fichier — probablement 1 ou 2 endroits maximum.
- [ ] **Step 3: Repérer le save dans `ModelTypeForm.vue`**
```bash
grep -n "POST\|PATCH\|api(\|emit('saved')\|emit('save'" frontend/app/components/model-types/ModelTypeForm.vue | head -20
```
- [ ] **Step 4: Ajouter l'invalidation après save**
Dans le `<script setup>` de `ModelTypeForm.vue` :
```ts
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions({ api: useApi() })
```
Puis, après la sauvegarde réussie du ModelType (typiquement après le `await api(...)` qui POST/PATCH `/api/model_types/...`) :
```ts
invalidateCustomFieldNames()
```
- [ ] **Step 5: Vérifier le typecheck**
```bash
cd frontend && npx nuxi typecheck
```
- [ ] **Step 6: Test manuel**
Scénario :
1. Ouvrir une machine, ajouter un champ perso « Test invalidation 2026 » et save.
2. Ouvrir une autre machine ou un ModelType.
3. Tenter d'ajouter un champ perso → taper « Test invalid » → vérifier que « Test invalidation 2026 » apparaît dans les suggestions.
- [ ] **Step 7: Commit**
```bash
git add frontend/app/composables/useMachineCustomFieldDefs.ts frontend/app/components/model-types/ModelTypeForm.vue
git commit -m "feat(custom-fields) : invalide le cache de suggestions apres save
Apres chaque save reussi de champs perso (machine ou ModelType), on
invalide le cache useCustomFieldNameSuggestions pour que les noms
nouvellement crees apparaissent dans les futures autocomplete."
```
---
## Task 11: Validation finale
**Files:** aucun changement, juste vérification end-to-end.
- [ ] **Step 1: Lancer le typecheck complet**
```bash
cd frontend && npx nuxi typecheck
```
Attendu : 0 errors.
- [ ] **Step 2: Lancer le linter complet**
```bash
cd frontend && npm run lint:fix
```
Attendu : 0 errors (ou seulement des fixes auto).
- [ ] **Step 3: Test end-to-end manuel**
Démarrer l'environnement local (`make start` si pas déjà fait), puis :
1. **Machine** : créer une machine, ajouter 2 champs perso « Numéro de série » et « Tension ». Save.
2. **ModelType** : créer un ModelType de catégorie composant, ajouter une pièce dans le skeleton, ajouter à cette pièce un champ perso. Vérifier que « Numéro de série » et « Tension » apparaissent dans les suggestions.
3. **Structure** : créer un ModelType de catégorie machine, naviguer dans la structure, ajouter un champ perso à un composant. Vérifier les suggestions.
4. **Création libre** : taper un nom inédit, voir la ligne « Créer ... », cliquer ou faire blur → garder le texte.
5. **Sélection** : cliquer sur une suggestion → input se remplit avec le nom exact.
- [ ] **Step 4: Vérifier le commit log**
```bash
git log --oneline -15
```
Confirmer qu'on a bien 1 commit par task, avec des messages cohérents.
- [ ] **Step 5: Push (optionnel, à confirmer avec l'utilisateur)**
```bash
git push
```
⚠️ Ne PAS push sans demande explicite de l'utilisateur.
---
## Récapitulatif des fichiers
### Créés
- `src/Controller/CustomFieldNamesController.php`
- `tests/Api/Controller/CustomFieldNamesControllerTest.php`
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- `frontend/app/components/common/CustomFieldNameInput.vue`
### Modifiés
- `frontend/app/components/common/SearchSelect.vue`
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
- `frontend/app/components/machine/MachineCustomFieldsCard.vue`
- `frontend/app/components/PieceModelStructureEditor.vue`
- `frontend/app/components/StructureNodeEditor.vue`
- `frontend/app/composables/useMachineCustomFieldDefs.ts`
- `frontend/app/components/model-types/ModelTypeForm.vue`

View File

@@ -0,0 +1,273 @@
# Custom Field Name Autocomplete — Design
**Date** : 2026-05-11
**Statut** : Design validé, prêt pour planification
## Contexte et problème
Aujourd'hui dans Inventory, on définit des "champs personnalisés" (custom fields) à plusieurs endroits :
- Au niveau d'une **machine** (entité `CustomField` avec FK `machineId`)
- Au niveau d'un **ModelType**, dans 3 contextes (composant / pièce / produit) : entité `CustomField` avec respectivement `typeComposantId`, `typePieceId`, `typeProductId`.
Côté frontend, l'éditeur de structure d'un ModelType expose des `customFields` array sur chaque node, mais lors du save le backend (`SkeletonStructureService::updateCustomFields`) traduit ça en entités `CustomField` persistées dans la table unique `custom_fields`. La table `custom_fields` est donc **l'unique source de vérité** pour tous les noms de champs perso de l'application.
À chaque création/modification, l'utilisateur saisit librement un **nom** dans un `<input>` texte. Conséquence : les mêmes concepts métier finissent écrits différemment (« Numéro de série », « N° série », « Num serie »), ce qui empêche toute uniformisation et complique les rapports/recherches.
**Objectif** : proposer une autocomplétion sur le nom du champ qui suggère les noms déjà existants dans la base, tout en autorisant la création libre d'un nouveau nom.
## Décisions clés
| Question | Choix retenu |
|----------|--------------|
| Scope des suggestions | **Cross-entité** (machine + composant + pièce + produit confondus) — objectif d'uniformisation globale |
| Comportement utilisateur | **Création libre** : si l'utilisateur tape un nom sans cliquer sur une suggestion, on garde son texte tel quel |
| Suggestion du type | Non : la suggestion porte uniquement sur le nom |
| Compteur d'usage | Non : on reste simple, juste les noms triés alpha |
| Pattern UI | **Étendre `SearchSelect.vue`** existant avec un prop `creatable` plutôt que datalist natif ou nouveau composant — cohérence visuelle avec le reste de l'app |
## Architecture
```
Backend Frontend
───────── ─────────
GET /api/custom-fields/names ◄── useCustomFieldNameSuggestions()
│ │ (cache module-level)
│ returns: ["Numéro...", ...] │
▼ ▼
SELECT DISTINCT name CustomFieldNameInput.vue (wrapper)
FROM custom_fields │
│ utilise
SearchSelect.vue (creatable=true)
│ utilisé par
┌───────────────┼─────────────────────┐
│ │ │
MachineCustomFieldDef- StructureNodeEditor PieceModelStructure-
Editor (composants) Editor (pièces)
MachineCustomFieldsCard
(édition inline d'une machine)
```
## Backend
### Nouveau endpoint : `GET /api/custom-fields/names`
**Fichier** : `src/Controller/CustomFieldNamesController.php`
**Sécurité** : `ROLE_VIEWER` (cohérent avec les autres GET sur `CustomField`).
**Format de réponse** : tableau JSON plat de strings, trié alphabétique, dédupliqué (case-insensitive sur l'union).
```json
["Numéro de série", "Puissance", "Tension nominale"]
```
> Pas de wrapper `hydra:` — ce n'est pas une resource API Platform mais un endpoint utilitaire.
### Implémentation SQL
Le controller exécute une seule requête SQL brute via `Doctrine\DBAL\Connection` :
```sql
SELECT DISTINCT name FROM custom_fields
WHERE name IS NOT NULL AND name <> ''
ORDER BY name ASC
```
> Toutes les sources de noms (machines, ModelType×composant/pièce/produit) convergent dans la même table `custom_fields` via les FKs `machineId`/`typeComposantId`/`typePieceId`/`typeProductId`. Pas de jointure ni de parsing JSON nécessaire — un simple `SELECT DISTINCT` suffit.
### Pas de cache HTTP
La liste change quand un utilisateur crée un nouveau champ perso. Le cache se fait côté frontend (cf. composable). Pas de header `Cache-Control` particulier.
## Frontend
### 1. Extension de `SearchSelect.vue`
**Nouveau prop** :
```js
creatable: {
type: Boolean,
default: false // strict par défaut → zéro régression sur les 10+ usages actuels
}
```
**Changements de comportement quand `creatable=true`** :
| Aspect | Mode strict (défaut) | Mode `creatable` |
|--------|---------------------|------------------|
| `modelValue` | ID de l'option | **Texte libre** (le nom est la valeur) |
| `handleInput` | emit `'search'` uniquement | emit aussi `'update:modelValue'` en temps réel |
| `closeDropdown` (blur) | reset au label de l'option sélectionnée | **garde** le texte tapé |
| Dropdown | liste filtrée | liste filtrée + une ligne **« Créer XYZ »** en bas si le texte tapé ne matche aucune option (icône `+`, texte plus discret) |
| Clavier | ↑/↓/Enter sélectionne une option | ↑/↓ navigue, Enter valide soit l'option soit le « Créer XYZ » |
**Garanti** : mode strict 100% inchangé → les 10+ usages actuels de `SearchSelect` ne sont pas affectés.
### 2. Composable `useCustomFieldNameSuggestions`
**Fichier** : `frontend/app/composables/useCustomFieldNameSuggestions.ts`
```ts
import { ref } from 'vue'
const cache = ref<string[] | null>(null)
const loading = ref(false)
interface Deps {
api: ReturnType<typeof useApi>
}
export function useCustomFieldNameSuggestions(deps: Deps) {
const { api } = deps
async function load(force = false) {
if (cache.value && !force) return cache.value
if (loading.value) return cache.value ?? []
loading.value = true
try {
cache.value = await api<string[]>('/api/custom-fields/names')
return cache.value
} finally {
loading.value = false
}
}
function invalidate() {
cache.value = null
}
return {
suggestions: cache,
loading,
load,
invalidate,
}
}
```
**Choix de design** :
- **Cache module-level** (déclaré hors de la fonction) → partagé entre toutes les instances du composable, donc une seule requête HTTP pour toute l'app.
- **Lazy load** au 1er focus → pas de surcoût au démarrage.
- **Invalidation manuelle** via `invalidate()` → appelée après chaque save de champ perso pour rafraîchir.
- **Pattern `Deps`** → cohérent avec la convention du projet (`interface Deps`, injection de `useApi`).
### 3. Composant wrapper `CustomFieldNameInput.vue`
**Fichier** : `frontend/app/components/common/CustomFieldNameInput.vue`
```vue
<template>
<SearchSelect
v-model="modelValue"
:options="options"
:placeholder="placeholder"
option-value="name"
option-label="name"
creatable
size="xs"
@focus="ensureLoaded"
/>
</template>
<script setup lang="ts">
import SearchSelect from './SearchSelect.vue'
import { computed } from 'vue'
const props = defineProps<{
modelValue: string
placeholder?: string
}>()
defineEmits<{ 'update:modelValue': [value: string] }>()
const { suggestions, load } = useCustomFieldNameSuggestions({ api: useApi() })
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
const ensureLoaded = () => load()
</script>
```
**Pourquoi un wrapper** : encapsule le branchement (load, map, props `creatable`/`option-value`) → impossible de l'oublier dans un consommateur, et tous les paramètres restent uniformes par construction.
### 4. Migration des éditeurs
Dans chacun des fichiers ci-dessous, **remplacer le `<input v-model="field.name">`** par :
```vue
<CustomFieldNameInput v-model="field.name" placeholder="Nom du champ" />
```
Fichiers concernés :
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (ligne ~36-41)
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (ligne ~57)
- `frontend/app/components/PieceModelStructureEditor.vue` (ligne ~97-102)
- `frontend/app/components/StructureNodeEditor.vue` (ligne ~106-111)
> Note : `CustomFieldNameInput` étant dans `components/common/`, il est auto-importé par Nuxt — pas besoin d'`import` dans les consommateurs.
### 5. Invalidation du cache
Après chaque save réussi de champs perso, appeler `invalidate()` pour que la prochaine ouverture du dropdown récupère les nouveaux noms.
| Endroit | Quand |
|---------|-------|
| `useMachineCustomFieldDefs` (composable existant) | Après PATCH/POST réussi des custom fields machine |
| `ModelTypeForm.vue` (save ModelType + skeleton requirements) | Après sauvegarde du ModelType |
Pattern :
```ts
const { invalidate } = useCustomFieldNameSuggestions({ api: useApi() })
async function save() {
await api(...) // sauvegarde existante
invalidate() // ← nouveau
}
```
> On n'a pas besoin d'invalider lors d'une simple modification d'un nom existant (au pire la liste a une suggestion en trop, ce n'est pas un bug). On invalide à chaque save pour rester simple.
## Comportement utilisateur
### Cas 1 — Création d'un nouveau champ
1. User clique « Ajouter un champ »
2. Un input vide apparaît
3. User clique dedans → dropdown s'ouvre avec tous les noms existants triés alpha
4. User tape « num » → dropdown filtre sur `["Numéro de lot", "Numéro de série"]`
5. User clique sur « Numéro de série » → l'input se remplit exactement avec « Numéro de série »
6. **OU** user tape « num XYZ » et clique ailleurs → l'input garde « num XYZ », une ligne « Créer 'num XYZ' » lui suggère explicitement la création
### Cas 2 — Modification d'un nom existant
1. User voit un champ existant nommé « Numéro de série »
2. User clique dans l'input → dropdown s'ouvre, suggestions filtrées sur « Numéro de série »
3. User efface et tape « Tension » → dropdown filtre, il peut sélectionner ou retaper librement
4. Pas de fusion automatique des données — chaque champ reste indépendant
### Cas 3 — Plusieurs inputs visibles en même temps
- Toutes les instances partagent le même cache (module-level) → une seule requête HTTP pour la session
- Si user crée un champ « Nouveau nom » dans l'input A et passe à l'input B sans rafraîchir, « Nouveau nom » apparaîtra dans les suggestions de B dès que `invalidate()` a été appelé au save
## Hors-scope
- **Renommage en cascade** : si on change un nom partout (ex: « Num serie » → « Numéro de série » pour les unifier), pas de migration automatique des champs existants. C'est un travail manuel, ou un futur outil dédié.
- **Compteur d'usage** : peut être ajouté plus tard sans changer l'API (format de réponse extensible).
- **Suggestion du type** : on ne propose pas un type par défaut quand l'utilisateur sélectionne une suggestion. À évaluer si besoin émerge.
- **Tests** : pas de tests Vue dans le projet actuellement → validation manuelle. Côté backend, un test PHPUnit du controller est recommandé (cf. plan d'implémentation).
## Fichiers impactés (résumé)
### Nouveaux fichiers
- `src/Controller/CustomFieldNamesController.php`
- `frontend/app/composables/useCustomFieldNameSuggestions.ts`
- `frontend/app/components/common/CustomFieldNameInput.vue`
### Fichiers modifiés
- `frontend/app/components/common/SearchSelect.vue` (ajout prop `creatable`)
- `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` (remplacer input)
- `frontend/app/components/machine/MachineCustomFieldsCard.vue` (remplacer input)
- `frontend/app/components/PieceModelStructureEditor.vue` (remplacer input)
- `frontend/app/components/StructureNodeEditor.vue` (remplacer input)
- `frontend/app/composables/useMachineCustomFieldDefs.ts` (ajout `invalidate()` après save)
- `frontend/app/components/model-types/ModelTypeForm.vue` (ajout `invalidate()` après save)

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="space-y-4"> <div class="space-y-3">
<!-- Root Components --> <!-- Root Components -->
<div v-for="component in components" :key="component.id" class="border border-gray-200 rounded-lg p-4"> <div v-for="component in components" :key="component.id">
<ComponentItem <ComponentItem
:component="component" :component="component"
:is-edit-mode="isEditMode" :is-edit-mode="isEditMode"

View File

@@ -13,29 +13,42 @@
@updated="handleDocumentUpdated" @updated="handleDocumentUpdated"
/> />
<!-- Component Header --> <!-- HEADER BAR -->
<div <div
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-shadow" class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
:class="[ :class="[
component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200', component.pendingEntity
!isCollapsed ? 'sticky top-16 z-10 shadow-sm' : '', ? 'bg-error/10 border border-error/40 hover:border-error/60'
: 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
!isCollapsed ? 'sticky top-16 z-10 shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
]" ]"
@click="toggleCollapse" @click="toggleCollapse"
> >
<IconLucideChevronRight <!-- Chevron -->
class="w-4 h-4 shrink-0 transition-transform text-base-content/50" <div
:class="{ 'rotate-90': !isCollapsed }" class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
aria-hidden="true" :class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
/> >
<div class="flex-1 min-w-0"> <IconLucideChevronRight
class="w-3.5 h-3.5 transition-transform duration-200"
:class="[
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
]"
aria-hidden="true"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-1.5">
<!-- Row 1: Name + identifiers -->
<div class="flex items-center gap-2 flex-wrap"> <div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'"> <h3 class="text-sm font-bold tracking-tight truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
<NuxtLink <NuxtLink
v-if="!isEditMode && !component.pendingEntity && component.composantId" v-if="!isEditMode && !component.pendingEntity && component.composantId"
:to="machineId :to="machineId
? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } } ? { path: `/component/${component.composantId}`, query: { from: 'machine', machineId } }
: `/component/${component.composantId}`" : `/component/${component.composantId}`"
class="hover:underline hover:text-primary transition-colors" class="hover:text-primary transition-colors"
@click.stop @click.stop
> >
{{ component.name }} {{ component.name }}
@@ -51,232 +64,287 @@
> >
À remplir À remplir
</button> </button>
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span> <span v-if="component.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ component.reference }}</span>
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}</span> <span v-if="component.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ component.referenceAuto }}</span>
<span v-if="component.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ component.prix }}</span>
</div> </div>
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
<!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
<div
v-if="visibleContextFieldTags.length"
class="flex flex-wrap items-center gap-2"
>
<span
v-for="field in visibleContextFieldTags"
:key="field.name"
class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
:class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags -->
<div
v-if="componentConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5"
>
<span <span
v-for="constructeur in componentConstructeursDisplay" v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id" :key="constructeur.id"
class="text-xs text-base-content/50" class="text-[0.65rem] text-base-content/45"
> >
{{ constructeur.name }} {{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span> <span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span> </span>
<span v-if="displayProductName" class="badge badge-info badge-xs"> <span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }} {{ displayProductName }}
</span> </span>
</div> </div>
</div> </div>
<!-- Delete button -->
<button <button
v-if="showDelete" v-if="showDelete"
type="button" type="button"
class="btn btn-ghost btn-xs text-error shrink-0" class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
title="Supprimer ce composant" title="Supprimer ce composant"
@click.stop="$emit('delete')" @click.stop="$emit('delete')"
> >
Supprimer <IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</div> </div>
<!-- Expanded content --> <!-- EXPANDED PANEL -->
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7"> <div v-show="!isCollapsed && !component.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
<!-- Info fields -->
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3"> <!-- Section: Informations -->
<div class="form-control"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent"> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
</div> </div>
<div class="form-control"> <div class="p-4">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label> <!-- Edit mode -->
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent"> <div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Nom</span></label>
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
</div>
<div class="form-control">
<label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
<ConstructeurSelect
class="w-full"
:model-value="componentConstructeurIds"
:initial-options="componentConstructeursDisplay"
@update:model-value="handleConstructeurChange"
/>
</div>
</div>
<!-- Read-only mode -->
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Nom</p>
<p class="text-sm text-base-content font-medium">{{ component.name }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
<p class="text-sm text-base-content" :class="component.reference ? 'font-mono' : 'text-base-content/30'">{{ component.reference || '—' }}</p>
</div>
<div v-if="component.referenceAuto">
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
<p class="text-sm text-base-content font-mono">{{ component.referenceAuto }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
<p class="text-sm" :class="component.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ component.prix ? `${component.prix}` : '—' }}</p>
</div>
<div>
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
<div v-if="componentConstructeursDisplay.length" class="space-y-1">
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-sm text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-sm text-base-content/30"></p>
</div>
</div>
</div> </div>
<div class="form-control"> </div>
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent"> <!-- Section: Produit catalogue -->
<div v-if="displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-info/10 border-b border-info/20">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
</div> </div>
<div class="form-control"> <div class="p-4">
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label> <div class="flex items-start justify-between gap-3">
<ConstructeurSelect <div class="space-y-1.5">
class="w-full" <p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
:model-value="componentConstructeurIds" <div class="flex flex-wrap gap-x-4 gap-y-1">
:initial-options="componentConstructeursDisplay" <p
@update:model-value="handleConstructeurChange" v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/55"
>
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
</div>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0 gap-1"
>
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
Voir
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200/50 space-y-2">
<p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/35">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Section: Champs personnalisés -->
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
</div>
<div class="p-4">
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="false"
@field-blur="updateComponentCustomField"
/> />
</div> </div>
</div> </div>
<!-- Read-only info --> <div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm"> <div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
<div> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
<p class="text-base-content">{{ component.name }}</p>
</div> </div>
<div> <div class="p-4">
<p class="text-xs text-base-content/40 mb-0.5">Référence</p> <CustomFieldDisplay
<p class="text-base-content">{{ component.reference || '—' }}</p> :fields="mergedContextFields"
</div> :is-edit-mode="isEditMode"
<div> :columns="2"
<p class="text-xs text-base-content/40 mb-0.5">Prix</p> :show-header="false"
<p class="text-base-content">{{ component.prix ? `${component.prix}` : '—' }}</p> :with-top-border="false"
</div> :editable="true"
<div> :emit-blur="false"
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p> @field-input="queueContextCustomFieldUpdate"
<div v-if="componentConstructeursDisplay.length"> />
<p
v-for="constructeur in componentConstructeursDisplay"
:key="constructeur.id"
class="text-base-content"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
Réf. {{ supplierReferenceMap.get(constructeur.id) }}
</span>
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
{{ formatConstructeurContact(constructeur) }}
</span>
</p>
</div>
<p v-else class="text-base-content"></p>
</div> </div>
</div> </div>
<!-- Product --> <!-- Section: Documents -->
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="flex items-start justify-between gap-3"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
<div class="space-y-1"> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
<p class="text-xs text-base-content/40">Produit catalogue</p>
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="text-xs text-base-content/60"
>
{{ info.label }} : {{ info.value }}
</p>
</div>
<NuxtLink
v-if="component.product?.id"
:to="`/product/${component.product.id}`"
class="btn btn-ghost btn-xs shrink-0"
>
Voir le produit
</NuxtLink>
</div>
<!-- Product documents -->
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
<div
v-for="document in productDocuments"
:key="document.id || document.path || document.name"
class="flex items-center justify-between gap-3 text-xs"
>
<div class="flex items-center gap-2 min-w-0">
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
<img
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
:src="document.fileUrl || document.path"
class="h-full w-full object-cover"
:alt="`Aperçu de ${document.name}`"
>
<iframe
v-else-if="shouldInlinePdf(document)"
:src="documentPreviewSrc(document)"
class="h-full w-full border-0 bg-white"
title="Aperçu PDF"
/>
<component
v-else
:is="documentIcon(document).component"
class="h-4 w-4"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<span class="truncate text-base-content">{{ document.name }}</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="btn btn-ghost btn-xs"
:disabled="!canPreviewDocument(document)"
@click="openPreview(document)"
>
Consulter
</button>
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
Télécharger
</button>
</div>
</div>
</div>
</div>
<!-- Custom Fields -->
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:columns="2"
title="Champs personnalisés item"
:editable="false"
@field-blur="updateComponentCustomField"
/>
<template v-if="mergedContextFields.length">
<div class="divider my-4 text-xs text-base-content/50">
Champs personnalisés machine
</div>
<CustomFieldDisplay
:fields="mergedContextFields"
:is-edit-mode="isEditMode"
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<!-- Documents -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs"> <span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }} {{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span> </span>
</div> </div>
<div class="p-4 space-y-3">
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement...</p>
<p v-if="loadingDocuments" class="text-xs text-base-content/50"> <DocumentUpload
Chargement... v-if="isEditMode"
</p> v-model="selectedFiles"
title="Déposer des fichiers pour ce composant"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<DocumentUpload <DocumentListInline
v-if="isEditMode" :documents="componentDocuments"
v-model="selectedFiles" :can-delete="isEditMode"
title="Déposer des fichiers pour ce composant" :can-edit="isEditMode"
subtitle="Formats acceptés : PDF, images, documents..." :delete-disabled="uploadingDocuments"
@files-added="handleFilesAdded" empty-text="Aucun document lié à ce composant."
/> @preview="openPreview"
@edit="openEditModal"
<DocumentListInline @delete="removeDocument"
:documents="componentDocuments" />
:can-delete="isEditMode" </div>
:can-edit="isEditMode"
:delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à ce composant."
@preview="openPreview"
@edit="openEditModal"
@delete="removeDocument"
/>
</div> </div>
<!-- Component Pieces (real MachinePieceLinks) --> <!-- Section: Pièces du composant -->
<div v-if="linkedPieces.length > 0" class="space-y-2"> <div v-if="linkedPieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Pièces du composant <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Pièces du composant
<div class="space-y-2"> <span class="ml-1 text-base-content/25">({{ linkedPieces.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<PieceItem <PieceItem
v-for="piece in linkedPieces" v-for="piece in linkedPieces"
:key="piece.id" :key="piece.id"
@@ -290,12 +358,15 @@
</div> </div>
</div> </div>
<!-- Structure pieces (read-only, from composant definition) --> <!-- ── Section: Pièces structure ── -->
<div v-if="structurePieces.length > 0" class="space-y-2"> <div v-if="structurePieces.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Pièces incluses par défaut <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Pièces incluses par défaut
<div class="space-y-2"> <span class="ml-1 text-base-content/25">({{ structurePieces.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<PieceItem <PieceItem
v-for="piece in structurePieces" v-for="piece in structurePieces"
:key="piece.id" :key="piece.id"
@@ -305,12 +376,15 @@
</div> </div>
</div> </div>
<!-- Sub Components --> <!-- ── Section: Sous-composants ── -->
<div v-if="childComponents.length > 0" class="space-y-2"> <div v-if="childComponents.length > 0" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide"> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
Sous-composants <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">
</p> Sous-composants
<div class="space-y-2 pl-4 border-l-2 border-base-200"> <span class="ml-1 text-base-content/25">({{ childComponents.length }})</span>
</p>
</div>
<div class="p-3 space-y-2">
<ComponentItem <ComponentItem
v-for="subComponent in childComponents" v-for="subComponent in childComponents"
:key="subComponent.id" :key="subComponent.id"
@@ -336,6 +410,8 @@ import DocumentUpload from './DocumentUpload.vue'
import ConstructeurSelect from './ConstructeurSelect.vue' import ConstructeurSelect from './ConstructeurSelect.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import IconLucideExternalLink from '~icons/lucide/external-link'
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview' import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { import {
@@ -461,6 +537,24 @@ const mergedContextFields = computed(() => {
return mergeDefinitionsWithValues(definitions, values) return mergeDefinitionsWithValues(definitions, values)
}) })
// Context fields shown as tags on the header (consultation mode)
const visibleContextFieldTags = computed(() =>
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
)
const CONTEXT_FIELD_COLORS = [
'bg-secondary/25 text-secondary border border-secondary/35',
'bg-accent/25 text-accent border border-accent/35',
'bg-info/25 text-info border border-info/35',
'bg-success/25 text-success border border-success/35',
'bg-warning/25 text-warning border border-warning/35',
]
const contextFieldBadgeClass = (field: any) => {
const idx = visibleContextFieldTags.value.indexOf(field)
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
}
const queueContextCustomFieldUpdate = (field, value) => { const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.component?.linkId const linkId = props.component?.linkId
if (!linkId || !field) return if (!linkId || !field) return

View File

@@ -124,6 +124,7 @@ import IconLucideCheck from '~icons/lucide/check'
import IconLucideX from '~icons/lucide/x' import IconLucideX from '~icons/lucide/x'
import { import {
type ConstructeurSummary, type ConstructeurSummary,
constructeurPhones,
formatConstructeurContact, formatConstructeurContact,
resolveConstructeurs, resolveConstructeurs,
uniqueConstructeurIds, uniqueConstructeurIds,
@@ -193,7 +194,7 @@ const filteredOptions = computed(() => {
return options.value.filter((option) => return options.value.filter((option) =>
(option.name ?? '').toLowerCase().includes(term) (option.name ?? '').toLowerCase().includes(term)
|| (option.email && option.email.toLowerCase().includes(term)) || (option.email && option.email.toLowerCase().includes(term))
|| (option.phone && option.phone.toLowerCase().includes(term)) || constructeurPhones(option).some(t => t.numero.toLowerCase().includes(term))
) )
}) })
@@ -293,14 +294,14 @@ const handleCreate = async () => {
} }
creating.value = true creating.value = true
const payload: { name: string; email?: string; phone?: string } = { const payload: { name: string; email?: string; telephones?: Array<{ numero: string }> } = {
name: trimmedName, name: trimmedName,
} }
if (createForm.value.email) { if (createForm.value.email) {
payload.email = createForm.value.email payload.email = createForm.value.email
} }
if (createForm.value.phone) { if (createForm.value.phone && createForm.value.phone.trim()) {
payload.phone = createForm.value.phone payload.telephones = [{ numero: createForm.value.phone.trim() }]
} }
const result = await createConstructeur(payload) const result = await createConstructeur(payload)
creating.value = false creating.value = false

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="space-y-4"> <div>
<DocumentPreviewModal <DocumentPreviewModal
:document="previewDocument" :document="previewDocument"
:visible="previewVisible" :visible="previewVisible"
@@ -13,303 +13,338 @@
@updated="handleDocumentUpdated" @updated="handleDocumentUpdated"
/> />
<!-- Piece Header (collapsible, same pattern as ComponentItem) --> <!-- HEADER BAR -->
<div class="flex items-start justify-between p-4 rounded-lg" :class="piece._emptySlot || piece.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'"> <div
<div class="flex items-start gap-3 flex-1 min-w-0"> class="group/header flex items-center gap-3 px-4 py-3 rounded-xl cursor-pointer select-none transition-all duration-200"
<button :class="[
type="button" piece._emptySlot || piece.pendingEntity
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform" ? 'bg-error/10 border border-error/40 hover:border-error/60'
:class="{ 'rotate-90': !isCollapsed }" : 'bg-base-200/70 border border-base-300/30 hover:border-base-300/60 hover:bg-base-200',
:aria-expanded="!isCollapsed" !isCollapsed ? 'shadow-md ring-1 ring-base-300/20' : 'shadow-sm',
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'" ]"
@click="toggleCollapse" @click="toggleCollapse"
> >
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" /> <!-- Chevron -->
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span> <div
</button> class="w-6 h-6 rounded-md grid place-items-center shrink-0 transition-all duration-200"
<div class="flex-1 min-w-0"> :class="isCollapsed ? 'bg-base-300/40' : 'bg-primary/15'"
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }"> >
<IconLucideChevronRight
class="w-3.5 h-3.5 transition-transform duration-200"
:class="[
isCollapsed ? 'text-base-content/40' : 'rotate-90 text-primary',
]"
aria-hidden="true"
/>
</div>
<!-- Content -->
<div class="flex-1 min-w-0 space-y-1.5">
<!-- Row 1: Name + identifiers -->
<div class="flex items-center gap-2 flex-wrap">
<h3 class="text-sm font-bold tracking-tight truncate" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
<NuxtLink <NuxtLink
v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId" v-if="!isEditMode && !piece.pendingEntity && !piece._emptySlot && piece.pieceId"
:to="machineId :to="machineId
? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } } ? { path: `/piece/${piece.pieceId}`, query: { from: 'machine', machineId } }
: `/piece/${piece.pieceId}`" : `/piece/${piece.pieceId}`"
class="hover:underline hover:text-primary transition-colors" class="hover:text-primary transition-colors"
@click.stop @click.stop
> >
{{ pieceData.name }} {{ pieceData.name }}
</NuxtLink> </NuxtLink>
<template v-else>{{ pieceData.name }}</template> <template v-else>{{ pieceData.name }}</template>
<span v-if="piece._emptySlot" class="text-sm font-semibold text-error ml-1"> manquant</span>
<button
v-if="piece.pendingEntity"
type="button"
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors ml-1"
title="Cliquer pour associer un item"
@click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
>
À remplir
</button>
<span
v-if="displayQuantity > 1"
class="text-sm font-normal text-base-content/60 ml-1"
>
×{{ displayQuantity }}
</span>
</h3> </h3>
<div class="flex flex-wrap gap-2 mt-2"> <span v-if="piece._emptySlot" class="text-[0.65rem] font-bold text-error bg-error/10 px-1.5 py-0.5 rounded">manquant</span>
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm"> <button
Rattachée à {{ piece.parentComponentName }} v-if="piece.pendingEntity"
</span> type="button"
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span> class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span> title="Cliquer pour associer un item"
<template v-if="pieceConstructeursDisplay.length"> @click.stop="$emit('fill-entity', piece.linkId, piece.modelTypeId)"
<span >
v-for="constructeur in pieceConstructeursDisplay" À remplir
:key="constructeur.id" </button>
class="badge badge-outline badge-sm" <span v-if="displayQuantity > 1" class="text-[0.65rem] font-bold text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">×{{ displayQuantity }}</span>
> <span v-if="pieceData.reference" class="text-[0.65rem] font-mono text-base-content/70 bg-base-300/50 px-1.5 py-0.5 rounded border border-base-300/40">{{ pieceData.reference }}</span>
{{ constructeur.name }} <span v-if="pieceData.referenceAuto" class="text-[0.65rem] font-mono font-semibold text-secondary bg-secondary/20 px-1.5 py-0.5 rounded border border-secondary/30" title="Référence auto">{{ pieceData.referenceAuto }}</span>
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5"> <span v-if="pieceData.prix" class="text-[0.65rem] font-bold text-primary bg-primary/20 px-1.5 py-0.5 rounded border border-primary/30">{{ pieceData.prix }}</span>
({{ supplierReferenceMap.get(constructeur.id) }}) </div>
</span>
</span> <!-- Row 1.5: Machine context fields (badges plus gros, visibles en lecture ET en edition) -->
</template> <div
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}</span> v-if="visibleContextFieldTags.length"
<span class="flex flex-wrap items-center gap-2"
v-if="displayProductName" >
class="badge badge-info badge-sm" <span
> v-for="field in visibleContextFieldTags"
Produit&nbsp;: {{ displayProductName }} :key="field.name"
</span> class="inline-flex items-baseline gap-1.5 px-2.5 py-1 rounded-md"
</div> :class="contextFieldBadgeClass(field)"
>
<span class="text-[0.65rem] font-semibold uppercase tracking-wide opacity-70">{{ field.name }}</span>
<span class="text-sm font-bold">{{ field.value }}</span>
</span>
</div>
<!-- Row 2: Metadata tags -->
<div
v-if="piece.parentComponentName || pieceConstructeursDisplay.length || displayProductName"
class="flex flex-wrap items-center gap-1.5"
>
<span v-if="piece.parentComponentName" class="text-[0.65rem] text-base-content/40 bg-base-300/20 px-1.5 py-0.5 rounded">
{{ piece.parentComponentName }}
</span>
<span
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="text-[0.65rem] text-base-content/45"
>
{{ constructeur.name }}
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-60">({{ supplierReferenceMap.get(constructeur.id) }})</span>
</span>
<span v-if="displayProductName" class="text-[0.65rem] font-semibold text-info bg-info/20 px-1.5 py-0.5 rounded border border-info/30">
{{ displayProductName }}
</span>
</div> </div>
</div> </div>
<!-- Delete button -->
<button <button
v-if="showDelete" v-if="showDelete"
type="button" type="button"
class="btn btn-ghost btn-xs text-error shrink-0" class="btn btn-ghost btn-xs btn-circle text-error opacity-0 group-hover/header:opacity-100 transition-opacity shrink-0"
title="Supprimer cette pièce" title="Supprimer cette pièce"
@click="$emit('delete')" @click.stop="$emit('delete')"
> >
Supprimer <IconLucideTrash2 class="w-3.5 h-3.5" aria-hidden="true" />
</button> </button>
</div> </div>
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4"> <!-- EXPANDED PANEL -->
<div class="p-4 bg-base-100 border border-base-200 rounded-lg"> <div v-show="!isCollapsed && !piece.pendingEntity" class="ml-[1.125rem] border-l-2 border-primary/30 pl-5 pt-3 pb-1 space-y-3">
<div class="space-y-2 text-sm">
<div v-if="isEditMode" class="form-control"> <!-- Section: Informations -->
<label class="label"> <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<span class="label-text text-sm">Quantité</span> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
</label> <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Informations</p>
<input </div>
v-model.number="pieceData.quantity" <div class="p-4">
type="number" <!-- Edit mode -->
min="1" <div v-if="isEditMode" class="space-y-3">
step="1" <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
class="input input-bordered input-sm md:input-md w-24" <div class="form-control">
@blur="updatePiece" <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Quantité</span></label>
/> <input
</div> v-model.number="pieceData.quantity"
<div v-else-if="displayQuantity > 1"> type="number"
<span class="font-medium">Quantité:</span> min="1"
<span class="ml-2">{{ displayQuantity }}</span> step="1"
</div> class="input input-bordered input-sm w-full"
<div> @blur="updatePiece"
<span class="font-medium">Référence:</span> />
<input </div>
v-if="isEditMode" <div class="form-control">
:id="`piece-reference-${piece.id}`" <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Référence</span></label>
v-model="pieceData.reference" <input
type="text" :id="`piece-reference-${piece.id}`"
class="input input-sm input-bordered ml-2" v-model="pieceData.reference"
@blur="updatePiece" type="text"
/> class="input input-bordered input-sm w-full"
<span v-else class="ml-2">{{ @blur="updatePiece"
pieceData.reference || "Non définie" />
}}</span> </div>
</div> <div class="form-control">
<div v-if="pieceData.referenceAuto"> <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Prix</span></label>
<span class="font-medium">Référence auto:</span> <input
<span class="ml-2">{{ pieceData.referenceAuto }}</span> :id="`piece-prix-${piece.id}`"
</div> v-model="pieceData.prix"
<div> type="number"
<span class="font-medium">Fournisseur:</span> step="0.01"
<div v-if="!isEditMode" class="ml-2"> class="input input-bordered input-sm w-full"
<div v-if="pieceConstructeursDisplay.length" class="space-y-1"> @blur="updatePiece"
<div />
v-for="constructeur in pieceConstructeursDisplay" </div>
:key="constructeur.id" </div>
class="flex flex-col" <div class="form-control">
> <label class="label py-0.5"><span class="label-text text-[0.65rem] font-semibold text-base-content/50 uppercase tracking-wide">Fournisseur</span></label>
<span class="font-medium"> <ConstructeurSelect
{{ constructeur.name }} class="w-full"
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60"> :model-value="pieceConstructeurIds"
Réf. {{ supplierReferenceMap.get(constructeur.id) }} :initial-options="pieceConstructeursDisplay"
</span> placeholder="Sélectionner un ou plusieurs fournisseurs..."
</span> @update:model-value="handleConstructeurChange"
<span />
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-base-content/50"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div> </div>
</div> </div>
<span v-else class="font-medium"> <!-- Read-only mode -->
Non défini <div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-4">
</span> <div v-if="displayQuantity > 1">
</div> <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Quantité</p>
<ConstructeurSelect <p class="text-sm text-base-content font-medium">{{ displayQuantity }}</p>
v-else </div>
class="w-full" <div>
:model-value="pieceConstructeurIds" <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Référence</p>
:initial-options="pieceConstructeursDisplay" <p class="text-sm" :class="pieceData.reference ? 'text-base-content font-mono' : 'text-base-content/30'">{{ pieceData.reference || '—' }}</p>
placeholder="Sélectionner un ou plusieurs fournisseurs..." </div>
@update:model-value="handleConstructeurChange" <div v-if="pieceData.referenceAuto">
/> <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Réf. auto</p>
</div> <p class="text-sm text-base-content font-mono">{{ pieceData.referenceAuto }}</p>
<div> </div>
<span class="font-medium">Prix:</span> <div>
<input <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Prix</p>
v-if="isEditMode" <p class="text-sm" :class="pieceData.prix ? 'text-base-content font-semibold' : 'text-base-content/30'">{{ pieceData.prix ? `${pieceData.prix}` : '—' }}</p>
:id="`piece-prix-${piece.id}`" </div>
v-model="pieceData.prix" <div>
type="number" <p class="text-[0.6rem] font-semibold uppercase tracking-wide text-base-content/50 mb-1">Fournisseur</p>
step="0.01" <div v-if="pieceConstructeursDisplay.length" class="space-y-1">
class="input input-sm input-bordered ml-2" <p
@blur="updatePiece" v-for="constructeur in pieceConstructeursDisplay"
/> :key="constructeur.id"
<span v-else class="ml-2">{{ class="text-sm text-base-content"
pieceData.prix ? `${pieceData.prix}` : "Non défini" >
}}</span> {{ constructeur.name }}
</div> <span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs text-base-content/50">
<div> Réf. {{ supplierReferenceMap.get(constructeur.id) }}
<span class="font-medium">Produit catalogue:</span> </span>
<div v-if="isEditMode" class="mt-2 space-y-2"> <span v-if="formatConstructeurContact(constructeur)" class="text-[0.65rem] text-base-content/40 block">
<ProductSelect {{ formatConstructeurContact(constructeur) }}
:model-value="pieceData.productId" </span>
placeholder="Associer un produit…" </p>
helper-text="Optionnel : reliez cette pièce à un produit catalogue." </div>
@update:modelValue="handleProductChange" <p v-else class="text-sm text-base-content/30"></p>
/> </div>
<div
v-if="selectedProduct"
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
>
<p class="text-sm font-semibold text-base-content">
{{ selectedProduct.name }}
</p>
<p
v-for="info in productInfoRows"
:key="info.label"
class="flex flex-wrap gap-1"
>
<span class="font-semibold">{{ info.label }} :</span>
<span>{{ info.value }}</span>
</p>
<NuxtLink
v-if="selectedProduct.id"
:to="`/product/${selectedProduct.id}`"
class="link link-primary text-xs"
>
Ouvrir la fiche produit
</NuxtLink>
</div> </div>
<p v-else class="text-xs text-base-content/60">
Aucun produit associé.
</p>
</div> </div>
<div class="ml-2"> </div>
<div v-if="displayProduct" class="space-y-1">
<p class="font-medium text-base-content"> <!-- Section: Produit catalogue -->
{{ displayProductName || 'Produit catalogue' }} <div v-if="isEditMode || displayProduct" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
</p> <div class="px-4 py-2 bg-info/10 border-b border-info/20">
<p <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-info">Produit catalogue</p>
v-for="info in productInfoRows" </div>
:key="info.label" <div class="p-4">
class="text-xs text-base-content/70" <!-- Edit mode -->
<div v-if="isEditMode" class="space-y-3">
<ProductSelect
:model-value="pieceData.productId"
placeholder="Associer un produit…"
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
@update:modelValue="handleProductChange"
/>
<div
v-if="selectedProduct"
class="rounded-lg border border-base-200/60 bg-base-200/20 p-3 space-y-1.5"
> >
<span class="font-semibold">{{ info.label }} :</span> <p class="text-sm font-bold text-base-content">{{ selectedProduct.name }}</p>
<span class="ml-1">{{ info.value }}</span> <div class="flex flex-wrap gap-x-4 gap-y-1">
</p> <p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
<NuxtLink v-if="selectedProduct.id" :to="`/product/${selectedProduct.id}`" class="link link-primary text-xs">
Ouvrir la fiche produit
</NuxtLink>
</div>
</div>
<!-- Read-only mode -->
<div v-else-if="displayProduct">
<div class="flex items-start justify-between gap-3">
<div class="space-y-1.5">
<p class="text-sm font-bold text-base-content">{{ displayProductName }}</p>
<div class="flex flex-wrap gap-x-4 gap-y-1">
<p v-for="info in productInfoRows" :key="info.label" class="text-xs text-base-content/55">
<span class="font-semibold">{{ info.label }}</span> : {{ info.value }}
</p>
</div>
</div>
<NuxtLink
v-if="piece.product?.id || piece.productId"
:to="`/product/${piece.product?.id || piece.productId}`"
class="btn btn-ghost btn-xs shrink-0 gap-1"
>
<IconLucideExternalLink class="w-3 h-3" aria-hidden="true" />
Voir
</NuxtLink>
</div>
<ProductDocumentsInline <ProductDocumentsInline
v-if="productDocuments.length"
class="mt-3 pt-3 border-t border-base-200/50"
:documents="productDocuments" :documents="productDocuments"
@preview="openPreview" @preview="openPreview"
/> />
</div> </div>
<span v-else class="font-medium">
Non défini
</span>
</div> </div>
</div> </div>
</div>
<!-- Section: Champs personnalisés item -->
<div v-if="displayedCustomFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
<div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Champs personnalisés item</p>
</div>
<div class="p-4">
<CustomFieldDisplay
:fields="displayedCustomFields"
:is-edit-mode="isEditMode"
:show-header="false"
:with-top-border="false"
:editable="false"
@field-input="handleCustomFieldInput"
@field-blur="handleCustomFieldBlur"
/>
</div>
</div> </div>
<!-- Champs personnalisés de la pièce --> <!-- Section: Champs personnalisés machine -->
<CustomFieldDisplay <div v-if="mergedContextFields.length" class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
:fields="displayedCustomFields" <div class="px-4 py-2 bg-secondary/10 border-b border-secondary/20">
:is-edit-mode="isEditMode" <p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-secondary">Champs personnalisés machine</p>
title="Champs personnalisés item" </div>
:editable="false" <div class="p-4">
@field-input="handleCustomFieldInput" <CustomFieldDisplay
@field-blur="handleCustomFieldBlur" :fields="mergedContextFields"
/> :is-edit-mode="isEditMode"
:columns="2"
<template v-if="mergedContextFields.length"> :show-header="false"
<div class="divider my-4 text-xs text-base-content/50"> :with-top-border="false"
Champs personnalisés machine :editable="true"
</div> :emit-blur="false"
<CustomFieldDisplay @field-input="queueContextCustomFieldUpdate"
:fields="mergedContextFields" />
:is-edit-mode="isEditMode" </div>
:columns="2"
:show-header="false"
:with-top-border="false"
:editable="true"
:emit-blur="false"
@field-input="queueContextCustomFieldUpdate"
/>
</template>
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
<span
v-if="isEditMode && selectedFiles.length"
class="badge badge-outline"
>
{{ selectedFiles.length }} fichier{{
selectedFiles.length > 1 ? "s" : ""
}}
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
</span>
</div> </div>
<p v-if="loadingDocuments" class="text-xs text-base-content/50"> <!-- Section: Documents -->
Chargement des documents... <div class="rounded-xl bg-base-100 border border-base-200/60 overflow-hidden">
</p> <div class="px-4 py-2 bg-base-200/30 border-b border-base-200/50 flex items-center justify-between">
<p class="text-[0.6rem] font-bold uppercase tracking-[0.15em] text-base-content/50">Documents</p>
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
</span>
</div>
<div class="p-4 space-y-3">
<p v-if="loadingDocuments" class="text-xs text-base-content/50">Chargement des documents...</p>
<DocumentUpload <DocumentUpload
v-if="isEditMode" v-if="isEditMode"
v-model="selectedFiles" v-model="selectedFiles"
title="Déposer des fichiers pour cette pièce" title="Déposer des fichiers pour cette pièce"
subtitle="Formats acceptés : PDF, images, documents..." subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded" @files-added="handleFilesAdded"
/> />
<DocumentListInline <DocumentListInline
:documents="pieceDocuments" :documents="pieceDocuments"
:can-delete="isEditMode" :can-delete="isEditMode"
:can-edit="isEditMode" :can-edit="isEditMode"
:delete-disabled="uploadingDocuments" :delete-disabled="uploadingDocuments"
empty-text="Aucun document lié à cette pièce." empty-text="Aucun document lié à cette pièce."
@preview="openPreview" @preview="openPreview"
@edit="openEditModal" @edit="openEditModal"
@delete="removeDocument" @delete="removeDocument"
/> />
</div> </div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -321,6 +356,8 @@ import ProductSelect from '~/components/ProductSelect.vue'
import DocumentUpload from '~/components/DocumentUpload.vue' import DocumentUpload from '~/components/DocumentUpload.vue'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue' import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideChevronRight from '~icons/lucide/chevron-right' import IconLucideChevronRight from '~icons/lucide/chevron-right'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import IconLucideExternalLink from '~icons/lucide/external-link'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useProducts } from '~/composables/useProducts' import { useProducts } from '~/composables/useProducts'
import { import {
@@ -468,6 +505,24 @@ const mergedContextFields = computed(() => {
return mergeDefinitionsWithValues(definitions, values) return mergeDefinitionsWithValues(definitions, values)
}) })
// Context fields shown as tags on the header (consultation mode)
const visibleContextFieldTags = computed(() =>
mergedContextFields.value.filter(f => f.value !== null && f.value !== undefined && String(f.value).trim() !== ''),
)
const CONTEXT_FIELD_COLORS = [
'bg-secondary/25 text-secondary border border-secondary/35',
'bg-accent/25 text-accent border border-accent/35',
'bg-info/25 text-info border border-info/35',
'bg-success/25 text-success border border-success/35',
'bg-warning/25 text-warning border border-warning/35',
]
const contextFieldBadgeClass = (field) => {
const idx = visibleContextFieldTags.value.indexOf(field)
return CONTEXT_FIELD_COLORS[idx % CONTEXT_FIELD_COLORS.length]
}
const queueContextCustomFieldUpdate = (field, value) => { const queueContextCustomFieldUpdate = (field, value) => {
const linkId = props.piece?.linkId const linkId = props.piece?.linkId
if (!linkId || !field) return if (!linkId || !field) return
@@ -689,12 +744,7 @@ watch(
) )
onMounted(() => { onMounted(() => {
pieceData.name = props.piece.name || ''
pieceData.reference = props.piece.reference || ''
pieceData.prix = props.piece.prix || ''
pieceData.quantity = props.piece.quantity ?? 1
loadProducts().catch(() => {}) loadProducts().catch(() => {})
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
if (!props.piece.documents?.length) refreshDocuments() if (!props.piece.documents?.length) refreshDocuments()
}) })
</script> </script>

View File

@@ -94,12 +94,11 @@
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input <CustomFieldNameInput
v-model="field.name" v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ" placeholder="Nom du champ"
> size="xs"
/>
<select v-model="field.type" class="select select-bordered select-xs"> <select v-model="field.type" class="select select-bordered select-xs">
<option value="text"> <option value="text">
Texte Texte

View File

@@ -103,11 +103,10 @@
</div> </div>
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input <CustomFieldNameInput
v-model="field.name" v-model="field.name"
type="text"
class="input input-bordered input-xs"
placeholder="Nom du champ" placeholder="Nom du champ"
size="xs"
/> />
<select v-model="field.type" class="select select-bordered select-xs"> <select v-model="field.type" class="select select-bordered select-xs">
<option value="text">Texte</option> <option value="text">Texte</option>

View File

@@ -0,0 +1,43 @@
<template>
<SearchSelect
:model-value="modelValue"
:options="options"
:placeholder="placeholder"
option-value="name"
option-label="name"
creatable
:size="size"
@update:model-value="onUpdate"
@focus="ensureLoaded"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import SearchSelect from './SearchSelect.vue'
const props = withDefaults(defineProps<{
modelValue: string
placeholder?: string
size?: 'xs' | 'sm' | 'md' | 'lg'
}>(), {
placeholder: 'Nom du champ',
size: 'xs',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { suggestions, load } = useCustomFieldNameSuggestions()
const options = computed(() => (suggestions.value ?? []).map(name => ({ name })))
function ensureLoaded(): void {
void load()
}
function onUpdate(value: string | number): void {
emit('update:modelValue', String(value ?? ''))
}
</script>

View File

@@ -77,6 +77,15 @@
</button> </button>
</li> </li>
</ul> </ul>
<button
v-if="creatableSuggestion"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-xs text-base-content/70 border-t border-base-200 flex items-center gap-2"
@click="confirmCreatable"
>
<IconLucidePlus class="w-3 h-3" aria-hidden="true" />
Créer « {{ creatableSuggestion }} »
</button>
</div> </div>
</transition> </transition>
</div> </div>
@@ -87,6 +96,7 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down' import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
import IconLucideX from '~icons/lucide/x' import IconLucideX from '~icons/lucide/x'
import IconLucidePlus from '~icons/lucide/plus'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -137,10 +147,14 @@ const props = defineProps({
serverSearch: { serverSearch: {
type: Boolean, type: Boolean,
default: false default: false
},
creatable: {
type: Boolean,
default: false
} }
}) })
const emit = defineEmits(['update:modelValue', 'search']) const emit = defineEmits(['update:modelValue', 'search', 'focus'])
const searchTerm = ref('') const searchTerm = ref('')
const openDropdown = ref(false) const openDropdown = ref(false)
@@ -172,6 +186,18 @@ const displayedOptions = computed(() => {
return filtered return filtered
}) })
const creatableSuggestion = computed(() => {
if (!props.creatable) return null
const term = searchTerm.value.trim()
if (!term) return null
// Show "Créer ..." only if no option matches exactly (case-insensitive)
const exists = baseOptions.value.some(option => {
const label = resolveLabel(option).toLowerCase()
return label === term.toLowerCase()
})
return exists ? null : term
})
const inputClasses = computed(() => { const inputClasses = computed(() => {
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10' const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
const base = ['input', 'input-bordered', 'w-full', pr] const base = ['input', 'input-bordered', 'w-full', pr]
@@ -194,6 +220,12 @@ const toggleButtonClasses = computed(() => {
watch( watch(
() => props.modelValue, () => props.modelValue,
() => { () => {
if (props.creatable) {
if (searchTerm.value !== props.modelValue) {
searchTerm.value = String(props.modelValue ?? '')
}
return
}
if (!openDropdown.value) { if (!openDropdown.value) {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : '' searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
} }
@@ -269,6 +301,7 @@ function handleFocus () {
if (searchTerm.value === '' && selectedOption.value) { if (searchTerm.value === '' && selectedOption.value) {
searchTerm.value = resolveLabel(selectedOption.value) searchTerm.value = resolveLabel(selectedOption.value)
} }
emit('focus')
} }
function toggleDropdown () { function toggleDropdown () {
@@ -285,6 +318,9 @@ function handleInput () {
if (!openDropdown.value) { if (!openDropdown.value) {
openDropdown.value = true openDropdown.value = true
} }
if (props.creatable) {
emit('update:modelValue', searchTerm.value)
}
emit('search', searchTerm.value) emit('search', searchTerm.value)
} }
@@ -294,8 +330,18 @@ function clearSelection () {
openDropdown.value = false openDropdown.value = false
} }
function confirmCreatable () {
if (creatableSuggestion.value) {
emit('update:modelValue', creatableSuggestion.value)
}
openDropdown.value = false
}
function closeDropdown () { function closeDropdown () {
openDropdown.value = false openDropdown.value = false
if (props.creatable) {
return // keep the typed text as-is
}
if (searchTerm.value.trim() === '' && selectedOption.value) { if (searchTerm.value.trim() === '' && selectedOption.value) {
emit('update:modelValue', '') emit('update:modelValue', '')
} else if (selectedOption.value) { } else if (selectedOption.value) {
@@ -342,7 +388,11 @@ const handleGlobalClick = (event) => {
onMounted(() => { onMounted(() => {
window.addEventListener('click', handleGlobalClick) window.addEventListener('click', handleGlobalClick)
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : '' if (props.creatable) {
searchTerm.value = String(props.modelValue ?? '')
} else {
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {

View File

@@ -0,0 +1,153 @@
<template>
<div class="constructeur-categorie-select space-y-2">
<div class="flex flex-wrap gap-2 min-h-[1.75rem]">
<span v-if="!selected.length" class="text-sm text-base-content/50">
Aucune catégorie
</span>
<span
v-for="cat in selected"
:key="cat.id || cat.name"
class="badge badge-outline badge-lg gap-1"
>
<span>{{ cat.name }}</span>
<button
v-if="!disabled"
type="button"
class="btn btn-ghost btn-xs p-0 h-auto min-h-0"
aria-label="Retirer la catégorie"
@click="removeCategory(cat)"
>
<IconLucideX class="w-3 h-3" aria-hidden="true" />
</button>
</span>
</div>
<div v-if="!disabled" class="relative">
<input
v-model="searchTerm"
type="text"
class="input input-bordered input-sm md:input-md w-full"
:placeholder="placeholder"
@focus="open = true; ensureLoaded()"
@keydown.escape="open = false"
>
<div
v-if="open && (matches.length || canCreate)"
class="absolute z-30 mt-1 w-full max-h-56 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
>
<button
v-for="cat in matches"
:key="cat.id"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm"
@click="addCategory(cat)"
>
{{ cat.name }}
</button>
<button
v-if="canCreate"
type="button"
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none text-sm text-primary"
@click="createAndAdd"
>
+ Créer « {{ searchTerm.trim() }} »
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import type { PropType } from 'vue'
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
import IconLucideX from '~icons/lucide/x'
const props = defineProps({
modelValue: {
type: Array as PropType<ConstructeurCategorie[]>,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: 'Rechercher ou créer une catégorie…',
},
})
const emit = defineEmits<{
(e: 'update:modelValue', value: ConstructeurCategorie[]): void
}>()
const { categories, loadCategories, createCategory } = useConstructeurCategories()
const searchTerm = ref('')
const open = ref(false)
const loadedOnce = ref(false)
const selected = computed<ConstructeurCategorie[]>(() => props.modelValue || [])
const selectedKeys = computed(() => new Set(selected.value.map(c => (c.name || '').toLowerCase())))
const matches = computed<ConstructeurCategorie[]>(() => {
const term = searchTerm.value.trim().toLowerCase()
return categories.value
.filter(c => !selectedKeys.value.has((c.name || '').toLowerCase()))
.filter(c => !term || (c.name || '').toLowerCase().includes(term))
.slice(0, 50)
})
const canCreate = computed(() => {
const term = searchTerm.value.trim()
if (!term) {
return false
}
const lower = term.toLowerCase()
return !categories.value.some(c => (c.name || '').toLowerCase() === lower)
&& !selectedKeys.value.has(lower)
})
const ensureLoaded = async () => {
if (loadedOnce.value) {
return
}
loadedOnce.value = true
await loadCategories()
}
const emitSelection = (value: ConstructeurCategorie[]) => {
emit('update:modelValue', value)
}
const addCategory = (cat: ConstructeurCategorie) => {
if (selectedKeys.value.has((cat.name || '').toLowerCase())) {
return
}
emitSelection([...selected.value, cat])
searchTerm.value = ''
}
const removeCategory = (cat: ConstructeurCategorie) => {
emitSelection(selected.value.filter(c => c !== cat && c.id !== cat.id))
}
const createAndAdd = async () => {
const created = await createCategory(searchTerm.value)
if (created) {
addCategory(created)
}
}
const onDocumentClick = (event: Event) => {
const target = event.target as HTMLElement | null
if (target && !target.closest('.constructeur-categorie-select')) {
open.value = false
}
}
onMounted(() => document.addEventListener('click', onDocumentClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocumentClick))
</script>

View File

@@ -33,12 +33,11 @@
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input <CustomFieldNameInput
v-model="field.name" v-model="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ" placeholder="Nom du champ"
> size="sm"
/>
<select v-model="field.type" class="select select-bordered select-sm"> <select v-model="field.type" class="select select-bordered select-sm">
<option value="text"> <option value="text">
Texte Texte

View File

@@ -50,12 +50,11 @@
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
<!-- Definition fields --> <!-- Definition fields -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-2"> <div class="grid grid-cols-1 md:grid-cols-3 gap-2">
<input <CustomFieldNameInput
:value="field.name" :model-value="field.name"
type="text"
class="input input-bordered input-sm"
placeholder="Nom du champ" placeholder="Nom du champ"
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)" size="sm"
@update:model-value="(value: string) => handleDefinitionUpdate(field, 'name', value)"
/> />
<select <select
:value="field.type || 'text'" :value="field.type || 'text'"

View File

@@ -204,7 +204,7 @@ const formulaBuilderCustomFields = computed(() => {
const extractFormulaFields = (formula: string | null | undefined): string[] => { const extractFormulaFields = (formula: string | null | undefined): string[] => {
if (!formula) return [] if (!formula) return []
const matches = [...formula.matchAll(/\{(\w+)\}/g)] const matches = [...formula.matchAll(/\{([^}]+)\}/gu)]
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))] return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
} }

View File

@@ -91,7 +91,7 @@ const preview = computed(() => {
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR') fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
} }
} }
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???') return props.modelValue.replace(/\{([^}]+)\}/gu, (_, name) => fieldMap.get(name) ?? '???')
}) })
const insertField = (fieldName: string) => { const insertField = (fieldName: string) => {

View File

@@ -0,0 +1,63 @@
import { ref } from 'vue'
import { useApi } from './useApi'
import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers'
export interface ConstructeurCategorie {
'@id'?: string
id: string
name: string
}
const categories = ref<ConstructeurCategorie[]>([])
const loading = ref(false)
const loaded = ref(false)
const sortByName = (items: ConstructeurCategorie[]): ConstructeurCategorie[] =>
[...items].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
export function useConstructeurCategories() {
const { get, post } = useApi()
const { showError } = useToast()
const loadCategories = async (force = false): Promise<ConstructeurCategorie[]> => {
if (loaded.value && !force) {
return categories.value
}
loading.value = true
try {
const result = await get('/constructeur_categories?itemsPerPage=1000')
if (result.success) {
categories.value = sortByName(extractCollection<ConstructeurCategorie>(result.data))
loaded.value = true
}
return categories.value
}
finally {
loading.value = false
}
}
const createCategory = async (name: string): Promise<ConstructeurCategorie | null> => {
const trimmed = name.trim()
if (!trimmed) {
return null
}
const existing = categories.value.find(c => c.name.toLowerCase() === trimmed.toLowerCase())
if (existing) {
return existing
}
const result = await post('/constructeur_categories', { name: trimmed })
if (result.success && result.data && !Array.isArray(result.data)) {
const created = result.data as ConstructeurCategorie
categories.value = sortByName([...categories.value, created])
return created
}
if (result.error) {
showError(result.error)
}
return null
}
return { categories, loading, loadCategories, createCategory }
}

View File

@@ -1,13 +1,30 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useApi } from './useApi' import { useApi } from './useApi'
import { useToast } from './useToast' import { useToast } from './useToast'
import { extractCollection } from '~/shared/utils/apiHelpers' import { extractCollection, extractTotal } from '~/shared/utils/apiHelpers'
export interface ConstructeurTelephone {
'@id'?: string
id?: string
numero: string
label?: string | null
}
export interface ConstructeurCategorieRef {
'@id'?: string
id: string
name: string
}
export interface Constructeur { export interface Constructeur {
'@id'?: string
id: string id: string
name: string name: string
email?: string | null email?: string | null
phone?: string | null telephones?: ConstructeurTelephone[]
categories?: ConstructeurCategorieRef[]
createdAt?: string
updatedAt?: string
} }
interface ConstructeurResult { interface ConstructeurResult {
@@ -16,6 +33,24 @@ interface ConstructeurResult {
error?: string error?: string
} }
export interface ConstructeurPageOptions {
page?: number
itemsPerPage?: number
search?: string
categoryId?: string
orderField?: 'name' | 'email' | 'createdAt'
orderDirection?: 'asc' | 'desc'
}
export interface ConstructeurPageResult {
success: boolean
items: Constructeur[]
totalItems: number
totalPages: number
currentPage: number
error?: string
}
const constructeurs = ref<Constructeur[]>([]) const constructeurs = ref<Constructeur[]>([])
const loading = ref(false) const loading = ref(false)
const loaded = ref(false) const loaded = ref(false)
@@ -66,8 +101,10 @@ export function useConstructeurs() {
} }
loading.value = true loading.value = true
try { try {
const query = search ? `?search=${encodeURIComponent(search)}` : '' const params = new URLSearchParams()
const result = await get(`/constructeurs${query}`) params.set('itemsPerPage', '2000')
if (search) params.set('search', search)
const result = await get(`/constructeurs?${params.toString()}`)
if (result.success) { if (result.success) {
const items = extractCollection(result.data) const items = extractCollection(result.data)
constructeurs.value = uniqueConstructeurs(items) constructeurs.value = uniqueConstructeurs(items)
@@ -87,7 +124,38 @@ export function useConstructeurs() {
return loadConstructeurs(search) return loadConstructeurs(search)
} }
const createConstructeur = async (data: Partial<Constructeur>): Promise<ConstructeurResult> => { const fetchConstructeursPage = async (opts: ConstructeurPageOptions = {}): Promise<ConstructeurPageResult> => {
const page = Math.max(1, opts.page ?? 1)
const itemsPerPage = Math.max(1, opts.itemsPerPage ?? 30)
loading.value = true
try {
const params = new URLSearchParams()
params.set('page', String(page))
params.set('itemsPerPage', String(itemsPerPage))
if (opts.search && opts.search.trim()) params.set('search', opts.search.trim())
if (opts.categoryId) params.set('categories.id', opts.categoryId)
if (opts.orderField) {
params.set(`order[${opts.orderField}]`, opts.orderDirection ?? 'asc')
}
const result = await get(`/constructeurs?${params.toString()}`)
if (!result.success) {
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: result.error }
}
const items = extractCollection<Constructeur>(result.data)
const totalItems = extractTotal(result.data, items.length)
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage))
upsertConstructeurs(items)
return { success: true, items, totalItems, totalPages, currentPage: page }
} catch (error) {
const err = error as Error
console.error('Erreur lors du chargement de la page fournisseurs:', error)
return { success: false, items: [], totalItems: 0, totalPages: 0, currentPage: page, error: err.message }
} finally {
loading.value = false
}
}
const createConstructeur = async (data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true loading.value = true
try { try {
const result = await post('/constructeurs', data) const result = await post('/constructeurs', data)
@@ -161,7 +229,7 @@ export function useConstructeurs() {
.filter((item): item is Constructeur => item !== null) .filter((item): item is Constructeur => item !== null)
} }
const updateConstructeur = async (id: string, data: Partial<Constructeur>): Promise<ConstructeurResult> => { const updateConstructeur = async (id: string, data: Record<string, unknown>): Promise<ConstructeurResult> => {
loading.value = true loading.value = true
try { try {
const result = await patch(`/constructeurs/${id}`, data) const result = await patch(`/constructeurs/${id}`, data)
@@ -210,6 +278,7 @@ export function useConstructeurs() {
loading, loading,
loadConstructeurs, loadConstructeurs,
searchConstructeurs, searchConstructeurs,
fetchConstructeursPage,
createConstructeur, createConstructeur,
updateConstructeur, updateConstructeur,
deleteConstructeur, deleteConstructeur,

View File

@@ -0,0 +1,41 @@
import { ref } from 'vue'
const cache = ref<string[] | null>(null)
const loading = ref(false)
export function useCustomFieldNameSuggestions() {
const api = useApi()
async function load(force = false): Promise<string[]> {
if (cache.value && !force) return cache.value
if (loading.value) return cache.value ?? []
loading.value = true
try {
const response = await api.get<string[]>('/custom-fields/names')
if (response.success && Array.isArray(response.data)) {
cache.value = response.data
}
else {
cache.value = cache.value ?? []
if (response.error) {
console.error('[useCustomFieldNameSuggestions] load failed:', response.error)
}
}
return cache.value
}
finally {
loading.value = false
}
}
function invalidate(): void {
cache.value = null
}
return {
suggestions: cache,
loading,
load,
invalidate,
}
}

View File

@@ -7,6 +7,7 @@
import { ref, type Ref } from 'vue' import { ref, type Ref } from 'vue'
import { useToast } from './useToast' import { useToast } from './useToast'
import { useCustomFieldNameSuggestions } from './useCustomFieldNameSuggestions'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { import {
listModelTypes, listModelTypes,
@@ -79,6 +80,7 @@ export function invalidateEntityTypeCache(category: ModelCategory) {
export function useEntityTypes(config: EntityTypeConfig) { export function useEntityTypes(config: EntityTypeConfig) {
const { category, label } = config const { category, label } = config
const { showSuccess, showError } = useToast() const { showSuccess, showError } = useToast()
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
const state = getOrCreateState(category) const state = getOrCreateState(category)
const normalizeItem = (item: ModelType): EntityType => ({ const normalizeItem = (item: ModelType): EntityType => ({
@@ -124,6 +126,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
}) })
const normalized = normalizeItem(data) const normalized = normalizeItem(data)
state.types.value.push(normalized) state.types.value.push(normalized)
invalidateCustomFieldNames()
showSuccess(`Type de ${label} "${data.name}" créé`) showSuccess(`Type de ${label} "${data.name}" créé`)
return { success: true, data: normalized } return { success: true, data: normalized }
} catch (error) { } catch (error) {
@@ -150,6 +153,7 @@ export function useEntityTypes(config: EntityTypeConfig) {
const normalized = normalizeItem(data) const normalized = normalizeItem(data)
const index = state.types.value.findIndex((t) => t.id === id) const index = state.types.value.findIndex((t) => t.id === id)
if (index !== -1) state.types.value[index] = normalized if (index !== -1) state.types.value[index] = normalized
invalidateCustomFieldNames()
showSuccess(`Type de ${label} "${data.name}" mis à jour`) showSuccess(`Type de ${label} "${data.name}" mis à jour`)
return { success: true, data: normalized } return { success: true, data: normalized }
} catch (error) { } catch (error) {

View File

@@ -1,5 +1,6 @@
import { reactive, ref } from 'vue' import { reactive, ref } from 'vue'
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import { useCustomFieldNameSuggestions } from '~/composables/useCustomFieldNameSuggestions'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
// --- Types --- // --- Types ---
@@ -88,6 +89,7 @@ const parseOptions = (optionsText: string): string[] =>
export function useMachineCustomFieldDefs(deps: Deps) { export function useMachineCustomFieldDefs(deps: Deps) {
const { apiCall } = useApi() const { apiCall } = useApi()
const { showSuccess, showError } = useToast() const { showSuccess, showError } = useToast()
const { invalidate: invalidateCustomFieldNames } = useCustomFieldNameSuggestions()
// --- State --- // --- State ---
@@ -294,6 +296,7 @@ export function useMachineCustomFieldDefs(deps: Deps) {
} }
showSuccess('Champs personnalisés sauvegardés avec succès') showSuccess('Champs personnalisés sauvegardés avec succès')
invalidateCustomFieldNames()
await deps.onSaved() await deps.onSaved()
} catch { } catch {
showError('Erreur inattendue lors de la sauvegarde des champs personnalisés') showError('Erreur inattendue lors de la sauvegarde des champs personnalisés')

View File

@@ -119,7 +119,6 @@ export function useMachineDetailData(machineId: string) {
if (!machineName.value.trim()) return false if (!machineName.value.trim()) return false
return true return true
}) })
const debug = ref(false)
const componentsCollapsed = ref(true) const componentsCollapsed = ref(true)
const collapseToggleToken = ref(0) const collapseToggleToken = ref(0)
@@ -227,22 +226,6 @@ export function useMachineDetailData(machineId: string) {
const componentTypeOptions = computed(() => componentTypes.value || []) const componentTypeOptions = computed(() => componentTypes.value || [])
const pieceTypeOptions = computed(() => pieceTypes.value || []) const pieceTypeOptions = computed(() => pieceTypes.value || [])
const componentTypeLabelMap = computed(() => {
const map = new Map<string, string>()
componentTypeOptions.value.forEach((type) => {
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
return map
})
const pieceTypeLabelMap = computed(() => {
const map = new Map<string, string>()
pieceTypeOptions.value.forEach((type) => {
if (type?.id) map.set(type.id as string, (type.name as string) || '')
})
return map
})
// Machine field methods // Machine field methods
const initMachineFields = () => { const initMachineFields = () => {
if (machine.value) { if (machine.value) {
@@ -306,7 +289,6 @@ export function useMachineDetailData(machineId: string) {
// UI methods // UI methods
const toggleEditMode = () => { const toggleEditMode = () => {
isEditMode.value = !isEditMode.value isEditMode.value = !isEditMode.value
debug.value = !debug.value
if (isEditMode.value && !machineDocumentsLoaded.value) { if (isEditMode.value && !machineDocumentsLoaded.value) {
refreshMachineDocuments() refreshMachineDocuments()
} }
@@ -432,12 +414,6 @@ export function useMachineDetailData(machineId: string) {
await productsPromise await productsPromise
const linksApplied = applyMachineLinks(machineResult.data) const linksApplied = applyMachineLinks(machineResult.data)
if (machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value
}
if (!linksApplied) { if (!linksApplied) {
components.value = transformComponentCustomFields(machinePayload.components || []) components.value = transformComponentCustomFields(machinePayload.components || [])
pieces.value = transformCustomFields(machinePayload.pieces || []) pieces.value = transformCustomFields(machinePayload.pieces || [])
@@ -447,6 +423,8 @@ export function useMachineDetailData(machineId: string) {
} }
if (machine.value) { if (machine.value) {
machine.value.componentLinks = machineComponentLinks.value
machine.value.pieceLinks = machinePieceLinks.value
machine.value.productLinks = machineProductLinks.value machine.value.productLinks = machineProductLinks.value
} }
@@ -496,11 +474,11 @@ export function useMachineDetailData(machineId: string) {
// UI state // UI state
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded, machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible, machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
isEditMode, debug, isEditMode,
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken, componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
// Computed // Computed
componentTypeOptions, pieceTypeOptions, componentTypeLabelMap, pieceTypeLabelMap, componentTypeOptions, pieceTypeOptions,
productInventory, productById, flattenedComponents, machinePieces, productInventory, productById, flattenedComponents, machinePieces,
machineDirectProducts, machineDocumentsList, visibleMachineCustomFields, machineDirectProducts, machineDocumentsList, visibleMachineCustomFields,

View File

@@ -33,7 +33,7 @@ export function useToast() {
message, message,
type, type,
visible: true, visible: true,
duration: type === 'error' ? 0 : duration, duration,
} }
if (toasts.value.length >= MAX_TOASTS) { if (toasts.value.length >= MAX_TOASTS) {
@@ -42,8 +42,7 @@ export function useToast() {
toasts.value.push(toast) toasts.value.push(toast)
// Only auto-dismiss non-error toasts if (duration > 0) {
if (type !== 'error' && duration > 0) {
setTimeout(() => { setTimeout(() => {
removeToast(id) removeToast(id)
}, duration) }, duration)
@@ -56,8 +55,8 @@ export function useToast() {
return showToast(message, 'success', duration) return showToast(message, 'success', duration)
} }
const showError = (message: string): number => { const showError = (message: string, duration = 8000): number => {
return showToast(message, 'error', 0) return showToast(message, 'error', duration)
} }
const showWarning = (message: string, duration = 6000): number => { const showWarning = (message: string, duration = 6000): number => {

View File

@@ -44,6 +44,7 @@
<option value="piece">Pièce</option> <option value="piece">Pièce</option>
<option value="product">Produit</option> <option value="product">Produit</option>
<option value="composant">Composant</option> <option value="composant">Composant</option>
<option value="machine">Machine</option>
</select> </select>
</div> </div>
@@ -89,13 +90,16 @@
<template #cell-entity="{ row }"> <template #cell-entity="{ row }">
<NuxtLink <NuxtLink
v-if="row.action !== 'delete'" v-if="row.action !== 'delete' && entityEditLink(row) !== '#'"
:to="entityEditLink(row)" :to="entityEditLink(row)"
class="link link-hover link-primary" class="link link-hover link-primary"
> >
{{ row.entityName || 'Sans nom' }} {{ row.entityName || 'Sans nom' }}
</NuxtLink> </NuxtLink>
<span v-else class="text-base-content/50 line-through"> <span v-else-if="row.action === 'delete'" class="text-base-content/50 line-through">
{{ row.entityName || 'Sans nom' }}
</span>
<span v-else>
{{ row.entityName || 'Sans nom' }} {{ row.entityName || 'Sans nom' }}
</span> </span>
<span <span
@@ -195,19 +199,23 @@ const ENTITY_TYPE_LABELS: Record<string, string> = {
piece: 'Pièce', piece: 'Pièce',
product: 'Produit', product: 'Produit',
composant: 'Composant', composant: 'Composant',
machine: 'Machine',
document: 'Document',
model_type: 'Modèle',
} }
const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type const entityTypeLabel = (type: string) => ENTITY_TYPE_LABELS[type] ?? type
const ENTITY_EDIT_ROUTES: Record<string, string> = { const ENTITY_ROUTES: Record<string, string> = {
piece: '/pieces', piece: '/piece',
product: '/product', product: '/product',
composant: '/component', composant: '/component',
machine: '/machine',
} }
const entityEditLink = (entry: ActivityLogEntry) => { const entityEditLink = (entry: ActivityLogEntry) => {
const base = ENTITY_EDIT_ROUTES[entry.entityType] ?? '' const base = ENTITY_ROUTES[entry.entityType] ?? ''
return base ? `${base}/${entry.entityId}/edit` : '#' return base ? `${base}/${entry.entityId}` : '#'
} }
const actionBadgeClass = (action: string) => { const actionBadgeClass = (action: string) => {

View File

@@ -159,6 +159,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await updateModelType(id, enrichedPayload) await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadComponentTypes({ force: true }) await loadComponentTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
} }
} catch (error) { } catch (error) {
@@ -183,6 +184,7 @@ const handleSyncConfirm = async () => {
confirmTypeChanges: !!hasModifications, confirmTypeChanges: !!hasModifications,
}) })
await loadComponentTypes({ force: true }) await loadComponentTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de composant mise à jour avec succès.') showSuccess('Catégorie de composant mise à jour avec succès.')
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))

View File

@@ -6,7 +6,7 @@
Fournisseurs Fournisseurs
</h1> </h1>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
Gérez les fournisseurs et leurs coordonnées. Gérez les fournisseurs, leurs coordonnées et leurs catégories.
</p> </p>
</div> </div>
<button v-if="canEdit" class="btn btn-primary" @click="openCreateModal"> <button v-if="canEdit" class="btn btn-primary" @click="openCreateModal">
@@ -19,29 +19,69 @@
<div class="card-body space-y-4"> <div class="card-body space-y-4">
<DataTable <DataTable
:columns="columns" :columns="columns"
:rows="filteredConstructeurs" :rows="pageItems"
:loading="loading" :loading="loading"
:sort="currentSort" :sort="currentSort"
:show-counter="false" :pagination="paginationState"
:show-counter="true"
:show-per-page="true"
empty-message="Aucun fournisseur trouvé." empty-message="Aucun fournisseur trouvé."
no-results-message="Aucun fournisseur trouvé." no-results-message="Aucun fournisseur trouvé."
@sort="handleSort" @sort="handleSort"
@update:current-page="onPageChange"
@update:per-page="onPerPageChange"
> >
<template #toolbar> <template #toolbar>
<label class="w-full sm:w-72"> <div class="flex flex-col sm:flex-row gap-3 w-full">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span> <label class="w-full sm:w-72">
<input <span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Recherche</span>
v-model="searchTerm" <input
type="search" v-model="searchTerm"
class="input input-bordered input-sm w-full mt-1" type="search"
placeholder="Nom, email ou téléphone" class="input input-bordered input-sm w-full mt-1"
@input="debouncedSearch" placeholder="Nom, email ou téléphone"
/> @input="debouncedSearch"
</label> >
</label>
<label class="w-full sm:w-64">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Catégorie</span>
<select
v-model="selectedCategoryId"
class="select select-bordered select-sm w-full mt-1"
>
<option value="">
Toutes les catégories
</option>
<option v-for="cat in allCategories" :key="cat.id" :value="cat.id">
{{ cat.name }}
</option>
</select>
</label>
</div>
</template> </template>
<template #cell-phone="{ row }"> <template #cell-telephones="{ row }">
{{ formatPhoneDisplay(row.phone) }} <div v-if="rowPhones(row).length" class="flex flex-col gap-0.5">
<span v-for="(tel, idx) in rowPhones(row)" :key="idx" class="whitespace-nowrap text-sm">
{{ formatPhoneDisplay(tel.numero) }}
<span v-if="tel.label" class="text-xs text-base-content/50">({{ tel.label }})</span>
</span>
</div>
<span v-else class="text-base-content/30"></span>
</template>
<template #cell-categories="{ row }">
<div v-if="row.categories && row.categories.length" class="flex flex-wrap gap-1">
<span
v-for="cat in row.categories"
:key="cat.id"
class="badge badge-ghost badge-sm cursor-pointer hover:badge-primary transition-colors"
@click="selectedCategoryId = cat.id"
>
{{ cat.name }}
</span>
</div>
<span v-else class="text-base-content/30"></span>
</template> </template>
<template #cell-createdAt="{ row }"> <template #cell-createdAt="{ row }">
@@ -96,7 +136,7 @@
</div> </div>
<dialog class="modal" :class="{ 'modal-open': modalOpen }"> <dialog class="modal" :class="{ 'modal-open': modalOpen }">
<div class="modal-box"> <div class="modal-box max-w-2xl">
<h3 class="font-bold text-lg mb-4"> <h3 class="font-bold text-lg mb-4">
{{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur {{ editingConstructeur ? (canEdit ? 'Modifier' : 'Détails du') : 'Nouveau' }} fournisseur
</h3> </h3>
@@ -105,10 +145,53 @@
<label class="label"><span class="label-text">Nom</span></label> <label class="label"><span class="label-text">Nom</span></label>
<input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required> <input v-model="form.name" type="text" class="input input-bordered" :disabled="!canEdit" required>
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" /> <FieldEmail v-model="form.email" label="Email" :disabled="!canEdit" />
<FieldPhone v-model="form.phone" label="Téléphone" :disabled="!canEdit" />
<div class="form-control">
<div class="flex items-center justify-between mb-1">
<span class="label-text">Téléphones</span>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-xs"
@click="addTelephoneRow"
>
<IconLucidePlus class="w-3 h-3 mr-1" aria-hidden="true" />
Ajouter
</button>
</div>
<p v-if="!form.telephones.length" class="text-sm text-base-content/50">
Aucun téléphone.
</p>
<div v-for="(tel, idx) in form.telephones" :key="idx" class="flex items-end gap-2 mb-2">
<div class="flex-1">
<FieldPhone v-model="tel.numero" label="" :disabled="!canEdit" placeholder="Ex: 05 49 00 00 00" />
</div>
<input
v-model="tel.label"
type="text"
class="input input-bordered input-sm md:input-md w-40"
placeholder="Libellé (optionnel)"
:disabled="!canEdit"
>
<button
v-if="canEdit"
type="button"
class="btn btn-ghost btn-sm text-error"
aria-label="Supprimer ce téléphone"
@click="removeTelephoneRow(idx)"
>
<IconLucideX class="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div> </div>
<div class="form-control">
<label class="label"><span class="label-text">Catégories</span></label>
<ConstructeurCategorieSelect v-model="form.categories" :disabled="!canEdit" />
</div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" @click="closeModal"> <button type="button" class="btn" @click="closeModal">
Annuler Annuler
@@ -125,26 +208,47 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import DataTable from '~/components/common/DataTable.vue' import DataTable from '~/components/common/DataTable.vue'
import FieldEmail from '~/components/form/FieldEmail.vue' import FieldEmail from '~/components/form/FieldEmail.vue'
import FieldPhone from '~/components/form/FieldPhone.vue' import FieldPhone from '~/components/form/FieldPhone.vue'
import ConstructeurCategorieSelect from '~/components/form/ConstructeurCategorieSelect.vue'
import { useConstructeurs } from '~/composables/useConstructeurs' import { useConstructeurs } from '~/composables/useConstructeurs'
import { useConstructeurCategories, type ConstructeurCategorie } from '~/composables/useConstructeurCategories'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { usePersistedValue } from '~/composables/usePersistedValue' import { usePersistedValue } from '~/composables/usePersistedValue'
import { constructeurPhones } from '~/shared/constructeurUtils'
import { formatPhone } from '~/utils/formatters/phone' import { formatPhone } from '~/utils/formatters/phone'
import { formatFrenchDate } from '~/utils/date' import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus' import IconLucidePlus from '~icons/lucide/plus'
import IconLucideX from '~icons/lucide/x'
interface TelephoneFormRow { '@id'?: string, numero: string, label: string }
interface ConstructeurFormState {
name: string
email: string
telephones: TelephoneFormRow[]
categories: ConstructeurCategorie[]
}
const api = useApi() const api = useApi()
const { canEdit } = usePermissions() const { canEdit } = usePermissions()
const { constructeurs, loading, searchConstructeurs, createConstructeur, updateConstructeur, deleteConstructeur, loadConstructeurs } = useConstructeurs() const { constructeurs, loading, createConstructeur, updateConstructeur, deleteConstructeur, fetchConstructeursPage } = useConstructeurs()
const { categories: allCategories, loadCategories } = useConstructeurCategories()
const { showError } = useToast() const { showError } = useToast()
const pageItems = ref<typeof constructeurs.value>([])
const totalItems = ref(0)
const totalPages = ref(0)
const currentPage = ref(1)
const perPage = ref(30)
const perPageOptions = [15, 30, 50, 100]
const columns = [ const columns = [
{ key: 'name', label: 'Nom', sortable: true }, { key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true }, { key: 'email', label: 'Email', sortable: true },
{ key: 'phone', label: 'Téléphone', sortable: true }, { key: 'telephones', label: 'Téléphones' },
{ key: 'categories', label: 'Catégories' },
{ key: 'createdAt', label: 'Date de création', sortable: true }, { key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'composantCount', label: 'Composants', align: 'center' }, { key: 'composantCount', label: 'Composants', align: 'center' },
{ key: 'pieceCount', label: 'Pièces', align: 'center' }, { key: 'pieceCount', label: 'Pièces', align: 'center' },
@@ -153,9 +257,10 @@ const columns = [
] ]
const searchTerm = ref('') const searchTerm = ref('')
const selectedCategoryId = ref('')
const sortKey = usePersistedValue('constructeurs-sort', 'name') const sortKey = usePersistedValue('constructeurs-sort', 'name')
const sortDir = ref('asc') const sortDir = ref('asc')
const stats = ref({}) const stats = ref<Record<string, { composantCount?: number, pieceCount?: number, machineCount?: number }>>({})
const currentSort = computed(() => ({ const currentSort = computed(() => ({
field: sortKey.value, field: sortKey.value,
@@ -167,40 +272,80 @@ const handleSort = (sort) => {
sortDir.value = sort.direction sortDir.value = sort.direction
} }
const paginationState = computed(() => ({
currentPage: currentPage.value,
totalPages: totalPages.value,
totalItems: totalItems.value,
pageItems: pageItems.value.length,
perPage: perPage.value,
perPageOptions,
}))
const SORTABLE_FIELDS = new Set(['name', 'email', 'createdAt'])
const loadPage = async () => {
const orderField = SORTABLE_FIELDS.has(sortKey.value)
? (sortKey.value as 'name' | 'email' | 'createdAt')
: 'name'
const result = await fetchConstructeursPage({
page: currentPage.value,
itemsPerPage: perPage.value,
search: searchTerm.value,
categoryId: selectedCategoryId.value || undefined,
orderField,
orderDirection: sortDir.value === 'desc' ? 'desc' : 'asc',
})
if (!result.success) {
if (result.error) showError(result.error)
pageItems.value = []
totalItems.value = 0
totalPages.value = 0
return
}
pageItems.value = result.items
totalItems.value = result.totalItems
totalPages.value = result.totalPages
if (currentPage.value > result.totalPages && result.totalPages > 0) {
currentPage.value = result.totalPages
}
}
const modalOpen = ref(false) const modalOpen = ref(false)
const saving = ref(false) const saving = ref(false)
const editingConstructeur = ref(null) const editingConstructeur = ref<Record<string, any> | null>(null)
const form = ref({ name: '', email: '', phone: '' }) const form = ref<ConstructeurFormState>({ name: '', email: '', telephones: [], categories: [] })
const filteredConstructeurs = computed(() => { const rowPhones = constructeurPhones
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1 const debouncedSearch = debounce(() => {
const sorted = [...constructeurs.value].sort((a, b) => { currentPage.value = 1
if (key === 'createdAt') { loadPage()
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime()) }, 300)
}
return dir * (a[key] || '').localeCompare(b[key] || '') watch(selectedCategoryId, () => {
}) currentPage.value = 1
if (!searchTerm.value) { return sorted } loadPage()
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 () => { watch([sortKey, sortDir], () => {
await searchConstructeurs(searchTerm.value) currentPage.value = 1
}, 300) loadPage()
})
const onPageChange = (page: number) => {
currentPage.value = page
loadPage()
}
const onPerPageChange = (value: number) => {
perPage.value = value
currentPage.value = 1
loadPage()
}
const formatDate = formatFrenchDate const formatDate = formatFrenchDate
const formatPhoneDisplay = (value) => { const formatPhoneDisplay = value => formatPhone(value) || value || '—'
const formatted = formatPhone(value)
if (formatted) {
return formatted
}
return value || '—'
}
function debounce(fn, delay) { function debounce(fn, delay) {
let timeout let timeout
@@ -211,7 +356,7 @@ function debounce(fn, delay) {
} }
const resetForm = () => { const resetForm = () => {
form.value = { name: '', email: '', phone: '' } form.value = { name: '', email: '', telephones: [], categories: [] }
editingConstructeur.value = null editingConstructeur.value = null
} }
@@ -225,7 +370,12 @@ const openEditModal = (constructeur) => {
form.value = { form.value = {
name: constructeur.name, name: constructeur.name,
email: constructeur.email || '', email: constructeur.email || '',
phone: constructeur.phone || '', telephones: (constructeur.telephones || []).map(t => ({
'@id': t['@id'],
numero: t.numero || '',
label: t.label || '',
})),
categories: (constructeur.categories || []).map(c => ({ ...c })),
} }
modalOpen.value = true modalOpen.value = true
} }
@@ -235,8 +385,20 @@ const closeModal = () => {
resetForm() resetForm()
} }
const addTelephoneRow = () => {
form.value.telephones.push({ numero: '', label: '' })
}
const removeTelephoneRow = (idx) => {
form.value.telephones.splice(idx, 1)
}
const saveConstructeur = async () => { const saveConstructeur = async () => {
const trimmedName = form.value.name.trim() const trimmedName = form.value.name.trim()
if (!trimmedName) {
showError('Le nom est obligatoire.')
return
}
const duplicate = constructeurs.value.find( const duplicate = constructeurs.value.find(
c => c.name.toLowerCase() === trimmedName.toLowerCase() c => c.name.toLowerCase() === trimmedName.toLowerCase()
&& c.id !== editingConstructeur.value?.id, && c.id !== editingConstructeur.value?.id,
@@ -247,9 +409,24 @@ const saveConstructeur = async () => {
} }
saving.value = true saving.value = true
const payload = { ...form.value, name: trimmedName } const payload = {
if (!payload.email) { delete payload.email } name: trimmedName,
if (!payload.phone) { delete payload.phone } email: form.value.email?.trim() || null,
telephones: form.value.telephones
.filter(t => t.numero && t.numero.trim())
.map((t) => {
const entry: { numero: string, label: string | null, '@id'?: string } = {
numero: t.numero.trim(),
label: t.label?.trim() || null,
}
if (t['@id']) { entry['@id'] = t['@id'] }
return entry
}),
categories: form.value.categories
.map(c => c['@id'] || (c.id ? `/api/constructeur_categories/${c.id}` : null))
.filter((iri): iri is string => Boolean(iri)),
}
let result let result
if (editingConstructeur.value) { if (editingConstructeur.value) {
result = await updateConstructeur(editingConstructeur.value.id, payload) result = await updateConstructeur(editingConstructeur.value.id, payload)
@@ -260,7 +437,7 @@ const saveConstructeur = async () => {
saving.value = false saving.value = false
if (result.success) { if (result.success) {
closeModal() closeModal()
await searchConstructeurs(searchTerm.value) await loadPage()
} }
} }
@@ -271,6 +448,10 @@ const confirmDelete = async (constructeur) => {
const result = await deleteConstructeur(constructeur.id) const result = await deleteConstructeur(constructeur.id)
if (!result.success && result.error) { if (!result.success && result.error) {
showError(result.error) showError(result.error)
return
}
if (result.success) {
await loadPage()
} }
} }
@@ -282,7 +463,8 @@ const loadStats = async () => {
} }
onMounted(() => { onMounted(() => {
loadConstructeurs() loadPage()
loadCategories()
loadStats() loadStats()
}) })
</script> </script>

View File

@@ -715,10 +715,12 @@
</p> </p>
<p class="text-base-content/70 leading-relaxed mb-4"> <p class="text-base-content/70 leading-relaxed mb-4">
<strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand <strong>Pourquoi ?</strong> Certaines informations n'ont de sens que quand
l'element est monte sur une machine. Par exemple, la "position sur la machine" l'element est monte sur une machine. Prenons l'exemple d'un palier : sur une
d'une pompe : dans le catalogue, la pompe n'est montee nulle part, donc ce champ machine, vous en avez souvent deux, un en haut (le palier de tete) et un en
ne sert a rien. Mais quand on regarde cette pompe depuis la fiche d'une machine, bas (le palier de pied). Dans le catalogue, le palier n'est monte nulle part,
on veut savoir ou elle est installee. donc savoir s'il est "en haut" ou "en bas" ne veut rien dire. Mais des qu'on
regarde ce palier depuis la fiche d'une machine, on veut savoir lequel des
deux c'est.
</p> </p>
</div> </div>
</div> </div>
@@ -731,16 +733,16 @@
<p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p> <p class="text-xs text-base-content/40 mb-4">Quand on consulte l'element tout seul</p>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">Debit max</span> <span class="text-base-content/70">Diametre interieur</span>
<span>120 L/min</span> <span>50 mm</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">ATEX</span> <span class="text-base-content/70">Type</span>
<span>Oui</span> <span>Roulement a billes</span>
</div> </div>
<div class="border-t border-dashed border-base-300 pt-2 mt-2"> <div class="border-t border-dashed border-base-300 pt-2 mt-2">
<div class="flex justify-between opacity-30"> <div class="flex justify-between opacity-30">
<span class="line-through">Position sur la machine</span> <span class="line-through">Emplacement</span>
<span class="text-xs italic">pas affiche ici</span> <span class="text-xs italic">pas affiche ici</span>
</div> </div>
</div> </div>
@@ -753,17 +755,17 @@
<p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p> <p class="text-xs text-base-content/40 mb-4">Quand on regarde l'element dans sa machine</p>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">Debit max</span> <span class="text-base-content/70">Diametre interieur</span>
<span>120 L/min</span> <span>50 mm</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-base-content/70">ATEX</span> <span class="text-base-content/70">Type</span>
<span>Oui</span> <span>Roulement a billes</span>
</div> </div>
<div class="border-t border-primary/20 pt-2 mt-2"> <div class="border-t border-primary/20 pt-2 mt-2">
<div class="flex justify-between bg-primary/10 rounded px-2 py-1.5"> <div class="flex justify-between bg-primary/10 rounded px-2 py-1.5">
<span class="text-base-content font-medium">Position sur la machine</span> <span class="text-base-content font-medium">Emplacement</span>
<span class="font-bold">Secteur B - Ligne 3</span> <span class="font-bold">Haut (palier de tete)</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -41,7 +41,7 @@
> >
</div> </div>
</div> </div>
<div class="form-control md:w-64"> <div class="form-control md:w-48">
<label class="label"> <label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span> <span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Site</span>
</label> </label>
@@ -58,6 +58,24 @@
</option> </option>
</select> </select>
</div> </div>
<div class="form-control">
<label class="label">
<span class="label-text text-xs font-semibold uppercase tracking-wide text-base-content/50">Date de création</span>
</label>
<div class="flex items-center gap-2">
<input
v-model="dateFrom"
type="date"
class="input input-bordered input-sm"
>
<span class="text-xs text-base-content/50">à</span>
<input
v-model="dateTo"
type="date"
class="input input-bordered input-sm"
>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -277,6 +295,8 @@ const showAddSiteModal = ref(false)
const showAddMachineModal = ref(false) const showAddMachineModal = ref(false)
const searchTerm = ref('') const searchTerm = ref('')
const selectedSiteFilter = ref('') const selectedSiteFilter = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const collapsedSites = ref([]) const collapsedSites = ref([])
const preselectedSiteId = ref('') const preselectedSiteId = ref('')
@@ -327,6 +347,25 @@ const filteredSites = computed(() => {
filtered = filtered.filter(site => site.id === selectedSiteFilter.value) filtered = filtered.filter(site => site.id === selectedSiteFilter.value)
} }
// Filtrer les machines par date de création
if (dateFrom.value || dateTo.value) {
const from = dateFrom.value ? new Date(dateFrom.value) : null
const to = dateTo.value ? new Date(dateTo.value) : null
if (from) from.setHours(0, 0, 0, 0)
if (to) to.setHours(23, 59, 59, 999)
filtered = filtered.map((site) => {
const filteredMachines = (site.machines || []).filter((machine) => {
if (!machine.createdAt) return false
const created = new Date(machine.createdAt)
if (from && created < from) return false
if (to && created > to) return false
return true
})
return { ...site, machines: filteredMachines }
}).filter(site => site.machines.length > 0)
}
// Filtrer par terme de recherche // Filtrer par terme de recherche
if (searchTerm.value) { if (searchTerm.value) {
filtered = filtered.filter((site) => { filtered = filtered.filter((site) => {

View File

@@ -5,108 +5,93 @@
<h2 class="text-2xl font-bold"> <h2 class="text-2xl font-bold">
Parc Machines Parc Machines
</h2> </h2>
<NuxtLink to="/machines/new" class="btn btn-primary"> <NuxtLink v-if="canEdit" to="/machines/new" class="btn btn-primary">
<IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" /> <IconLucidePlus class="w-5 h-5 mr-2" aria-hidden="true" />
Ajouter une machine Ajouter une machine
</NuxtLink> </NuxtLink>
</div> </div>
<div class="card bg-base-100 shadow-sm mb-6"> <div class="card bg-base-100 shadow-sm">
<div class="card-body"> <div class="card-body space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <DataTable
<div class="form-control"> :columns="columns"
<label class="label"> :rows="filteredMachines"
<span class="label-text">Sites</span> :loading="loading"
</label> :sort="currentSort"
<div class="flex flex-wrap gap-3"> :show-counter="true"
<label empty-message="Aucune machine trouvée."
v-for="site in sites" no-results-message="Aucune machine ne correspond à vos filtres."
:key="site.id" @sort="handleSort"
class="flex items-center gap-2 cursor-pointer" >
<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="searchQuery"
type="search"
class="input input-bordered input-sm w-full mt-1"
placeholder="Nom ou référence..."
> >
<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> </label>
<input
v-model="searchQuery" <div class="flex flex-col">
type="text" <span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Site</span>
placeholder="Rechercher par nom ou référence..." <select v-model="selectedSiteId" class="select select-bordered select-sm mt-1">
class="input input-bordered" <option value="">Tous les sites</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
</div>
<div class="flex flex-col">
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/70">Date de création</span>
<div class="flex items-center gap-2 mt-1">
<input
v-model="dateFrom"
type="date"
class="input input-bordered input-sm"
>
<span class="text-xs text-base-content/50">à</span>
<input
v-model="dateTo"
type="date"
class="input input-bordered input-sm"
>
</div>
</div>
</template>
<template #cell-site="{ row }">
<span
v-if="row.site"
class="badge badge-sm font-bold"
:style="row.site.color ? { backgroundColor: row.site.color + '30', color: row.site.color, borderColor: row.site.color + '50' } : {}"
:class="!row.site.color ? 'badge-ghost' : ''"
> >
</div> {{ row.site.name }}
</div> </span>
</div> <span v-else class="text-base-content/30"></span>
</div> </template>
<div v-if="loading" class="flex justify-center items-center py-12"> <template #cell-createdAt="{ row }">
<span class="loading loading-spinner loading-lg" /> <span class="whitespace-nowrap">{{ formatDate(row.createdAt) }}</span>
</div> </template>
<EmptyState <template #cell-actions="{ row }">
v-else-if="filteredMachines.length === 0" <div class="flex items-center justify-end gap-2">
:icon="IconLucideFactory" <button v-if="canEdit" class="btn btn-ghost btn-xs" @click="editMachine(row)">
title="Aucune machine trouvée" Modifier
description="Commencez par ajouter votre première machine." </button>
action-label="Ajouter une machine" <button v-if="canEdit" class="btn btn-ghost btn-xs text-error" @click="confirmDeleteMachine(row)">
action-to="/machines/new" Supprimer
/> </button>
<NuxtLink :to="`/machine/${row.id}`" class="btn btn-primary btn-xs">
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> Détails
<div </NuxtLink>
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>
</template>
<div v-if="machine.reference" class="flex items-center gap-2"> </DataTable>
<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> </div>
</div> </div>
@@ -114,16 +99,16 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted } from 'vue'
import DataTable from '~/components/common/DataTable.vue'
import { useMachines } from '~/composables/useMachines' import { useMachines } from '~/composables/useMachines'
import { useSites } from '~/composables/useSites' import { useSites } from '~/composables/useSites'
import { useToast } from '~/composables/useToast' import { useToast } from '~/composables/useToast'
import { humanizeError } from '~/shared/utils/errorMessages' import { humanizeError } from '~/shared/utils/errorMessages'
import { useUrlState } from '~/composables/useUrlState' import { useUrlState } from '~/composables/useUrlState'
import { usePersistedValue } from '~/composables/usePersistedValue'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus' 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 { canEdit } = usePermissions()
const { machines, loading, loadMachines, deleteMachine } = useMachines() const { machines, loading, loadMachines, deleteMachine } = useMachines()
@@ -132,34 +117,46 @@ const toast = useToast()
const urlState = useUrlState({ const urlState = useUrlState({
q: { default: '', debounce: 300 }, q: { default: '', debounce: 300 },
sites: { default: '' }, site: { default: '' },
from: { default: '' },
to: { default: '' },
}) })
const searchQuery = urlState.q const searchQuery = urlState.q
const selectedSites = reactive(new Set()) const selectedSiteId = urlState.site
const dateFrom = urlState.from
const dateTo = urlState.to
// Sync URL → selectedSites on load and back/forward const sortKey = usePersistedValue('machines-sort', 'name')
watch(urlState.sites, (val) => { const sortDir = ref('asc')
selectedSites.clear()
if (val) {
for (const id of String(val).split(',')) {
if (id) selectedSites.add(id)
}
}
}, { immediate: true })
// Sync selectedSites → URL const currentSort = computed(() => ({
watch(() => [...selectedSites], (ids) => { field: sortKey.value,
urlState.sites.value = ids.join(',') direction: sortDir.value,
}) }))
const handleSort = (sort) => {
sortKey.value = sort.field
sortDir.value = sort.direction
}
const columns = [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence', sortable: true },
{ key: 'site', label: 'Site', sortable: true, sortKey: 'siteName' },
{ key: 'createdAt', label: 'Date de création', sortable: true },
{ key: 'actions', label: 'Actions', align: 'right' },
]
const formatDate = formatFrenchDate
// Enrichir les machines avec les objets site complets
const enrichedMachines = computed(() => { const enrichedMachines = computed(() => {
return machines.value.map((machine) => { return machines.value.map((machine) => {
const site = sites.value.find(s => s.id === machine.siteId) const site = sites.value.find(s => s.id === machine.siteId)
return { return {
...machine, ...machine,
site: site || null, site: site || null,
siteName: site?.name || '',
} }
}) })
}) })
@@ -167,29 +164,44 @@ const enrichedMachines = computed(() => {
const filteredMachines = computed(() => { const filteredMachines = computed(() => {
let filtered = enrichedMachines.value let filtered = enrichedMachines.value
if (selectedSites.size > 0) { if (selectedSiteId.value) {
filtered = filtered.filter(machine => selectedSites.has(machine.siteId)) filtered = filtered.filter(m => m.siteId === selectedSiteId.value)
} }
if (searchQuery.value.trim()) { if (searchQuery.value.trim()) {
const term = searchQuery.value.trim().toLowerCase() const term = searchQuery.value.trim().toLowerCase()
filtered = filtered.filter(machine => filtered = filtered.filter(m =>
machine.name?.toLowerCase().includes(term) m.name?.toLowerCase().includes(term)
|| machine.reference?.toLowerCase().includes(term), || m.reference?.toLowerCase().includes(term),
) )
} }
filtered = [...filtered].sort((a, b) => if (dateFrom.value) {
(a.name || '').localeCompare(b.name || '', 'fr') const from = new Date(dateFrom.value)
) from.setHours(0, 0, 0, 0)
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) >= from)
}
if (dateTo.value) {
const to = new Date(dateTo.value)
to.setHours(23, 59, 59, 999)
filtered = filtered.filter(m => m.createdAt && new Date(m.createdAt) <= to)
}
const key = sortKey.value
const dir = sortDir.value === 'desc' ? -1 : 1
filtered = [...filtered].sort((a, b) => {
if (key === 'createdAt') {
return dir * (new Date(a[key] || 0).getTime() - new Date(b[key] || 0).getTime())
}
const valA = (key === 'siteName' ? a.siteName : a[key]) || ''
const valB = (key === 'siteName' ? b.siteName : b[key]) || ''
return dir * String(valA).localeCompare(String(valB), 'fr')
})
return filtered return filtered
}) })
const viewMachineDetails = (machine) => {
navigateTo(`/machine/${machine.id}`)
}
const editMachine = (machine) => { const editMachine = (machine) => {
navigateTo(`/machine/${machine.id}?edit=true`) navigateTo(`/machine/${machine.id}?edit=true`)
} }

View File

@@ -157,6 +157,7 @@ const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
await updateModelType(id, enrichedPayload) await updateModelType(id, enrichedPayload)
await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false }) await syncExecute(id, { confirmDeletions: false, confirmTypeChanges: false })
await loadPieceTypes({ force: true }) await loadPieceTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
} }
} catch (error) { } catch (error) {
@@ -181,6 +182,7 @@ const handleSyncConfirm = async () => {
confirmTypeChanges: !!hasModifications, confirmTypeChanges: !!hasModifications,
}) })
await loadPieceTypes({ force: true }) await loadPieceTypes({ force: true })
await loadCategory()
showSuccess('Catégorie de pièce mise à jour avec succès.') showSuccess('Catégorie de pièce mise à jour avec succès.')
} catch (error) { } catch (error) {
showError(normalizeError(error)) showError(normalizeError(error))

View File

@@ -1,12 +1,49 @@
import { formatPhone } from '~/utils/formatters/phone'; import { formatPhone } from '~/utils/formatters/phone';
export interface ConstructeurTelephoneSummary {
numero?: string | null;
label?: string | null;
}
export interface ConstructeurSummary { export interface ConstructeurSummary {
id: string; id: string;
name?: string | null; name?: string | null;
email?: string | null; email?: string | null;
// Legacy single-phone string: still exposed by the machine-structure normalization.
phone?: string | null; phone?: string | null;
// Multi-phone list: exposed by the /constructeurs API resource.
telephones?: ConstructeurTelephoneSummary[] | null;
} }
type ConstructeurPhoneSource = {
phone?: string | null;
telephones?: ConstructeurTelephoneSummary[] | null;
} | null | undefined;
export const constructeurPhones = (
constructeur: ConstructeurPhoneSource,
): Array<{ numero: string; label: string | null }> => {
if (!constructeur) {
return [];
}
const list = Array.isArray(constructeur.telephones)
? constructeur.telephones
.filter((t): t is ConstructeurTelephoneSummary => Boolean(t && t.numero && String(t.numero).trim()))
.map(t => ({ numero: String(t.numero).trim(), label: (t.label ?? null) || null }))
: [];
if (!list.length && constructeur.phone && constructeur.phone.trim()) {
return [{ numero: constructeur.phone.trim(), label: null }];
}
return list;
};
export const constructeurPrimaryPhone = (
constructeur: ConstructeurPhoneSource,
): string | null => {
const phones = constructeurPhones(constructeur);
return phones.length ? phones[0]!.numero : null;
};
export interface ConstructeurLinkEntry { export interface ConstructeurLinkEntry {
linkId?: string; linkId?: string;
constructeurId: string; constructeurId: string;
@@ -133,8 +170,8 @@ export const formatConstructeurContact = (
return ''; return '';
} }
const formattedPhone = formatPhone(constructeur.phone); const primary = constructeurPrimaryPhone(constructeur);
const phone = formattedPhone || constructeur.phone || null; const phone = formatPhone(primary) || primary || null;
return [constructeur.email, phone].filter(Boolean).join(' • '); return [constructeur.email, phone].filter(Boolean).join(' • ');
}; };

View File

@@ -98,6 +98,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz",
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@@ -2092,6 +2093,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2", "arg": "^5.0.2",
@@ -4112,6 +4114,7 @@
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==", "integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -4181,6 +4184,7 @@
"integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==", "integrity": "sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.44.1", "@typescript-eslint/scope-manager": "8.44.1",
"@typescript-eslint/types": "8.44.1", "@typescript-eslint/types": "8.44.1",
@@ -4977,6 +4981,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.22.tgz",
"integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==", "integrity": "sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.28.4", "@babel/parser": "^7.28.4",
"@vue/compiler-core": "3.5.22", "@vue/compiler-core": "3.5.22",
@@ -5207,6 +5212,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -5637,6 +5643,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@@ -7069,6 +7076,7 @@
"integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==", "integrity": "sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -10490,6 +10498,7 @@
"integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deep-is": "^0.1.3", "deep-is": "^0.1.3",
"fast-levenshtein": "^2.0.6", "fast-levenshtein": "^2.0.6",
@@ -10536,6 +10545,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz", "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.87.0.tgz",
"integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==", "integrity": "sha512-uc47XrtHwkBoES4HFgwgfH9sqwAtJXgAIBq4fFBMZ4hWmgVZoExyn+L4g4VuaecVKXkz1bvlaHcfwHAJPQb5Gw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@oxc-project/types": "^0.87.0" "@oxc-project/types": "^0.87.0"
}, },
@@ -10937,6 +10947,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.11", "nanoid": "^3.3.11",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -11376,6 +11387,7 @@
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
"util-deprecate": "^1.0.2" "util-deprecate": "^1.0.2"
@@ -12118,6 +12130,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.2.tgz",
"integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==", "integrity": "sha512-I25/2QgoROE1vYV+NQ1En9T9UFB9Cmfm2CJ83zZOlaDpvz29wGQSZXWKw7MiNXau7wYgB/T9fVIdIuEQ+KbiiA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
}, },
@@ -13180,6 +13193,7 @@
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -13537,6 +13551,7 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"napi-postinstall": "^0.3.0" "napi-postinstall": "^0.3.0"
}, },
@@ -13783,6 +13798,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.7.tgz",
"integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==", "integrity": "sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -14186,6 +14202,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.22.tgz",
"integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==", "integrity": "sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.22", "@vue/compiler-dom": "3.5.22",
"@vue/compiler-sfc": "3.5.22", "@vue/compiler-sfc": "3.5.22",
@@ -14230,6 +14247,7 @@
"integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==", "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"debug": "^4.4.0", "debug": "^4.4.0",
"eslint-scope": "^8.2.0", "eslint-scope": "^8.2.0",
@@ -14253,6 +14271,7 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==", "integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^6.6.4" "@vue/devtools-api": "^6.6.4"
}, },

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData' import { mockLinkSKF, mockLinkFAG } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks — API layer // Mocks — API layer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -206,12 +212,6 @@ vi.mock('~/shared/constructeurUtils', () => ({
constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId), constructeurIdsFromLinks: (links: any[]) => links.map((l: any) => l.constructeurId),
})) }))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentCreate } from '~/composables/useComponentCreate'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -8,6 +8,12 @@ import {
wrapCollection, wrapCollection,
} from '../fixtures/mockData' } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentEdit } from '~/composables/useComponentEdit'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks — API layer // Mocks — API layer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -222,12 +228,6 @@ vi.mock('~/utils/documentPreview', () => ({
canPreviewDocument: () => false, canPreviewDocument: () => false,
})) }))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useComponentEdit } from '~/composables/useComponentEdit'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test data — component with structure containing slots // Test data — component with structure containing slots
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { wrapCollection } from '../fixtures/mockData' import { wrapCollection } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useDocuments } from '~/composables/useDocuments'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks — API layer // Mocks — API layer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -40,12 +46,6 @@ vi.mock('~/composables/useToast', () => ({
}), }),
})) }))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { useDocuments } from '~/composables/useDocuments'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test data // Test data
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -1,6 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
// ---------------------------------------------------------------------------
// Import under test (after mocks)
// ---------------------------------------------------------------------------
import { useMachineDetailData } from '~/composables/useMachineDetailData'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mock data — realistic /machines/{id}/structure response // Mock data — realistic /machines/{id}/structure response
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -345,12 +351,6 @@ vi.mock('~/shared/utils/documentDisplayUtils', () => ({
downloadDocument: vi.fn(), downloadDocument: vi.fn(),
})) }))
// ---------------------------------------------------------------------------
// Import under test (after mocks)
// ---------------------------------------------------------------------------
import { useMachineDetailData } from '~/composables/useMachineDetailData'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Setup // Setup
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -9,6 +9,12 @@ import {
wrapCollection, wrapCollection,
} from '../fixtures/mockData' } from '../fixtures/mockData'
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mocks — API layer // Mocks — API layer
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -183,12 +189,6 @@ vi.mock('~/shared/apiRelations', () => ({
}, },
})) }))
// ---------------------------------------------------------------------------
// Import under test (AFTER all vi.mock calls)
// ---------------------------------------------------------------------------
import { usePieceEdit } from '~/composables/usePieceEdit'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Test data // Test data
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -7,6 +7,10 @@ services:
- "8082:80" - "8082:80"
volumes: volumes:
- ./storage:/var/www/html/var/storage/documents - ./storage:/var/www/html/var/storage/documents
- inventory_logs:/var/www/html/var/log
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
volumes:
inventory_logs:

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260506140000_FixComposantCascadeFKs extends AbstractMigration
{
public function getDescription(): string
{
return 'Add missing CASCADE FKs documents.composantid and machine_component_links.composantid; cleanup pre-existing orphan rows';
}
public function up(Schema $schema): void
{
// 1. Trace des suppressions à venir dans audit_logs (actor = NULL = "system").
// On copie un snapshot minimal avant DELETE pour cohérence avec les autres "delete".
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'document',
d.id,
'delete',
json_build_object(
'id', d.id,
'name', d.name,
'filename', d.filename,
'composantId', d.composantid,
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
),
NULL,
NOW()
FROM documents d
WHERE d.composantid IS NOT NULL
AND d.composantid NOT IN (SELECT id FROM composants)
SQL);
$this->addSql(<<<'SQL'
INSERT INTO audit_logs (id, entitytype, entityid, action, snapshot, actorprofileid, createdat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
'machine_component_link',
l.id,
'delete',
json_build_object(
'id', l.id,
'machineId', l.machineid,
'composantId', l.composantid,
'note', 'Cleaned by FK cascade fix migration (Version20260506140000) - referenced composant no longer existed'
),
NULL,
NOW()
FROM machine_component_links l
WHERE l.composantid IS NOT NULL
AND l.composantid NOT IN (SELECT id FROM composants)
SQL);
// 2. Nettoyage des orphelins.
$this->addSql(<<<'SQL'
DELETE FROM documents
WHERE composantid IS NOT NULL
AND composantid NOT IN (SELECT id FROM composants)
SQL);
$this->addSql(<<<'SQL'
DELETE FROM machine_component_links
WHERE composantid IS NOT NULL
AND composantid NOT IN (SELECT id FROM composants)
SQL);
// 3. Ajout idempotent des 2 FK manquantes (alignement avec les entités Doctrine).
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_documents_composant' AND table_name = 'documents'
) THEN
ALTER TABLE documents ADD CONSTRAINT fk_documents_composant
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
END IF;
END $$;
SQL);
$this->addSql(<<<'SQL'
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE constraint_name = 'fk_mcl_composant' AND table_name = 'machine_component_links'
) THEN
ALTER TABLE machine_component_links ADD CONSTRAINT fk_mcl_composant
FOREIGN KEY (composantid) REFERENCES composants(id) ON DELETE CASCADE;
END IF;
END $$;
SQL);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE documents DROP CONSTRAINT IF EXISTS fk_documents_composant');
$this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_mcl_composant');
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260512150000_AddConstructeurCategoriesAndPhones extends AbstractMigration
{
public function getDescription(): string
{
return 'Add constructeur_categorie + constructeur_categories (M2M) + constructeur_telephone (1-N); migrate constructeurs.phone into constructeur_telephone then drop the phone column';
}
public function up(Schema $schema): void
{
// 1. Référentiel de catégories de fournisseurs.
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_categorie (
id VARCHAR(36) NOT NULL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
// 2. Table de jointure many-to-many fournisseur <-> catégorie.
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_categories (
constructeur_id VARCHAR(36) NOT NULL REFERENCES constructeurs(id) ON DELETE CASCADE,
categorie_id VARCHAR(36) NOT NULL REFERENCES constructeur_categorie(id) ON DELETE CASCADE,
PRIMARY KEY(constructeur_id, categorie_id)
)
SQL);
$this->addSql('CREATE INDEX IF NOT EXISTS idx_constructeur_categories_categorie ON constructeur_categories (categorie_id)');
// 3. Téléphones (un fournisseur peut en avoir plusieurs).
$this->addSql(<<<'SQL'
CREATE TABLE IF NOT EXISTS constructeur_telephone (
id VARCHAR(36) NOT NULL PRIMARY KEY,
constructeurid VARCHAR(36) NOT NULL REFERENCES constructeurs(id) ON DELETE CASCADE,
numero VARCHAR(50) NOT NULL,
label VARCHAR(100) DEFAULT NULL,
createdat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
updatedat TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL
)
SQL);
$this->addSql('CREATE INDEX IF NOT EXISTS idx_constructeur_telephone_constructeur ON constructeur_telephone (constructeurid)');
// 4. Migration des téléphones existants (colonne unique) vers la nouvelle table.
$this->addSql(<<<'SQL'
INSERT INTO constructeur_telephone (id, constructeurid, numero, label, createdat, updatedat)
SELECT
'cl' || substring(md5(random()::text || clock_timestamp()::text || c.id), 1, 24),
c.id,
trim(c.phone),
NULL,
NOW(),
NOW()
FROM constructeurs c
WHERE c.phone IS NOT NULL AND trim(c.phone) <> ''
SQL);
// 5. La colonne unique n'est plus la source de vérité.
$this->addSql('ALTER TABLE constructeurs DROP COLUMN IF EXISTS phone');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE constructeurs ADD COLUMN IF NOT EXISTS phone VARCHAR(255) DEFAULT NULL');
// Restaure un téléphone par fournisseur (le plus récemment créé), best-effort.
$this->addSql(<<<'SQL'
UPDATE constructeurs c
SET phone = t.numero
FROM (
SELECT DISTINCT ON (constructeurid) constructeurid, numero
FROM constructeur_telephone
ORDER BY constructeurid, createdat DESC
) t
WHERE t.constructeurid = c.id
SQL);
$this->addSql('DROP TABLE IF EXISTS constructeur_telephone');
$this->addSql('DROP TABLE IF EXISTS constructeur_categories');
$this->addSql('DROP TABLE IF EXISTS constructeur_categorie');
}
}

View File

@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace App\Command;
use DateTimeImmutable;
use Doctrine\DBAL\Connection;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
#[AsCommand(
name: 'app:convert-moteur-piece-to-component',
description: 'Convertit la catégorie "Moteur" (PIECE) en COMPONENT et migre toutes les pièces liées en composants.',
)]
class ConvertMoteurPieceToComponentCommand extends Command
{
private const MODEL_TYPE_ID = 'cmgytewe0002447ffup09bscr';
public function __construct(
private readonly Connection $connection,
) {
parent::__construct();
}
protected function configure(): void
{
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'Affiche les actions sans les exécuter');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$dryRun = $input->getOption('dry-run');
$io->title('Conversion catégorie "Moteur" : PIECE → COMPONENT');
// ── 1. Vérifications ──────────────────────────────────────────────
$modelType = $this->connection->fetchAssociative(
'SELECT id, name, code, category FROM model_types WHERE id = :id',
['id' => self::MODEL_TYPE_ID],
);
if (!$modelType) {
$io->error('ModelType "Moteur" introuvable (id: '.self::MODEL_TYPE_ID.')');
return Command::FAILURE;
}
if ('PIECE' !== $modelType['category']) {
$io->error(sprintf('Le ModelType "Moteur" est déjà de catégorie %s — rien à faire.', $modelType['category']));
return Command::FAILURE;
}
$pieces = $this->connection->fetchAllAssociative(
'SELECT id, name, reference FROM pieces WHERE typepieceid = :id ORDER BY name',
['id' => self::MODEL_TYPE_ID],
);
$pieceCount = count($pieces);
$io->info(sprintf('Pièces à convertir : %d', $pieceCount));
if ($pieceCount > 0) {
$io->table(
['ID', 'Nom', 'Référence'],
array_map(fn (array $p) => [$p['id'], $p['name'], $p['reference'] ?? '—'], $pieces),
);
}
// Check blockers
$blockers = [];
$machineLinked = (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM machine_piece_links mpl
JOIN pieces p ON mpl.pieceid = p.id
WHERE p.typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
if ($machineLinked > 0) {
$blockers[] = sprintf('%d pièce(s) liée(s) à des machines — conversion impossible.', $machineLinked);
}
$nameCollisions = $this->connection->fetchFirstColumn(
'SELECT p.name FROM pieces p
WHERE p.typepieceid = :id
AND p.name IN (SELECT c.name FROM composants c)',
['id' => self::MODEL_TYPE_ID],
);
if ([] !== $nameCollisions) {
$blockers[] = sprintf('Collision de noms avec des composants existants : %s', implode(', ', $nameCollisions));
}
$categoryCollision = (int) $this->connection->fetchOne(
"SELECT COUNT(*) FROM model_types WHERE category = 'COMPONENT' AND name = :name AND id != :id",
['name' => $modelType['name'], 'id' => self::MODEL_TYPE_ID],
);
if ($categoryCollision > 0) {
$blockers[] = sprintf('Un ModelType composant « %s » existe déjà.', $modelType['name']);
}
if ([] !== $blockers) {
$io->error($blockers);
return Command::FAILURE;
}
// Summary of related data
$relatedCounts = $this->countRelatedData();
$io->section('Données liées à migrer');
$io->table(
['Table', 'Nombre'],
array_map(fn (string $k, int $v) => [$k, $v], array_keys($relatedCounts), array_values($relatedCounts)),
);
if ($dryRun) {
$io->warning('Mode dry-run : aucune modification effectuée.');
return Command::SUCCESS;
}
// ── 2. Exécution ──────────────────────────────────────────────────
$this->connection->beginTransaction();
try {
$now = new DateTimeImmutable()->format('Y-m-d H:i:s');
// 2a. Copier les pièces dans composants
$converted = $this->connection->executeStatement(
'INSERT INTO composants (id, name, reference, referenceauto, description, prix, typecomposantid, productid, version, createdat, updatedat)
SELECT id, name, reference, referenceauto, description, prix, typepieceid, productid, version, createdat, :now
FROM pieces
WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$io->text(sprintf('✓ %d pièce(s) copiée(s) dans composants', $converted));
// 2b. Transférer les documents
$docs = $this->connection->executeStatement(
'UPDATE documents SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d document(s) transféré(s)', $docs));
// 2c. Transférer les custom_field_values
$cfv = $this->connection->executeStatement(
'UPDATE custom_field_values SET composantid = pieceid, pieceid = NULL
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d valeur(s) de champs perso transférée(s)', $cfv));
// 2d. Transférer les custom_fields (définitions)
$cf = $this->connection->executeStatement(
'UPDATE custom_fields SET typecomposantid = typepieceid, typepieceid = NULL
WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d définition(s) de champs perso transférée(s)', $cf));
// 2e. Transférer les constructeur links
$ctorLinks = $this->connection->executeStatement(
"INSERT INTO composant_constructeur_links (id, composantid, constructeurid, supplierreference, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
pcl.pieceid, pcl.constructeurid, pcl.supplierreference, pcl.createdat, :now
FROM piece_constructeur_links pcl
WHERE pcl.pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
if ($ctorLinks > 0) {
$this->connection->executeStatement(
'DELETE FROM piece_constructeur_links
WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => self::MODEL_TYPE_ID],
);
}
$io->text(sprintf('✓ %d lien(s) constructeur transféré(s)', $ctorLinks));
// 2f. Convertir composant_piece_slots → composant_subcomponent_slots
$slots = $this->connection->executeStatement(
"INSERT INTO composant_subcomponent_slots (id, composantid, alias, familycode, typecomposantid, selectedcomposantid, position, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
cps.composantid,
COALESCE(sp.name, 'Moteur'),
'moteur',
cps.typepieceid,
cps.selectedpieceid,
cps.position,
cps.createdat,
:now
FROM composant_piece_slots cps
LEFT JOIN pieces sp ON sp.id = cps.selectedpieceid
WHERE cps.typepieceid = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$this->connection->executeStatement(
'DELETE FROM composant_piece_slots WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d slot(s) pièce convertis en slots sous-composant', $slots));
// 2g. Convertir skeleton_piece_requirements → skeleton_subcomponent_requirements
$skelReqs = $this->connection->executeStatement(
"INSERT INTO skeleton_subcomponent_requirements (id, modeltypeid, alias, familycode, typecomposantid, position, createdat, updatedat)
SELECT 'cl' || substring(md5(random()::text || clock_timestamp()::text), 1, 24),
spr.modeltypeid,
'Moteur',
'moteur',
spr.typepieceid,
spr.position,
spr.createdat,
:now
FROM skeleton_piece_requirements spr
WHERE spr.typepieceid = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$this->connection->executeStatement(
'DELETE FROM skeleton_piece_requirements WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d skeleton requirement(s) convertis', $skelReqs));
// 2h. Mettre à jour audit_logs entity_type
$auditUpdated = $this->connection->executeStatement(
"UPDATE audit_logs SET entitytype = 'composant'
WHERE entitytype = 'piece'
AND entityid IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d audit log(s) mis à jour', $auditUpdated));
// 2i. Mettre à jour comments entity_type
$commentsUpdated = $this->connection->executeStatement(
"UPDATE comments SET entity_type = 'composant'
WHERE entity_type = 'piece'
AND entity_id IN (SELECT id FROM pieces WHERE typepieceid = :id)",
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d commentaire(s) mis à jour', $commentsUpdated));
// 2j. Supprimer les pièces originales
$deleted = $this->connection->executeStatement(
'DELETE FROM pieces WHERE typepieceid = :id',
['id' => self::MODEL_TYPE_ID],
);
$io->text(sprintf('✓ %d pièce(s) supprimée(s)', $deleted));
// 2k. Changer la catégorie du ModelType
$this->connection->executeStatement(
"UPDATE model_types SET category = 'COMPONENT', updatedat = :now WHERE id = :id",
['id' => self::MODEL_TYPE_ID, 'now' => $now],
);
$io->text('✓ ModelType "Moteur" passé en COMPONENT');
$this->connection->commit();
$io->success(sprintf('Conversion terminée : %d pièces → composants.', $converted));
return Command::SUCCESS;
} catch (Throwable $e) {
$this->connection->rollBack();
$io->error('Erreur — rollback effectué : '.$e->getMessage());
return Command::FAILURE;
}
}
/**
* @return array<string, int>
*/
private function countRelatedData(): array
{
$id = self::MODEL_TYPE_ID;
return [
'pieces' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM pieces WHERE typepieceid = :id',
['id' => $id],
),
'documents' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM documents WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'custom_field_values' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM custom_field_values WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'custom_fields' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM custom_fields WHERE typepieceid = :id',
['id' => $id],
),
'piece_constructeur_links' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM piece_constructeur_links WHERE pieceid IN (SELECT id FROM pieces WHERE typepieceid = :id)',
['id' => $id],
),
'composant_piece_slots (→ subcomponent_slots)' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM composant_piece_slots WHERE typepieceid = :id',
['id' => $id],
),
'skeleton_piece_requirements (→ subcomponent_reqs)' => (int) $this->connection->fetchOne(
'SELECT COUNT(*) FROM skeleton_piece_requirements WHERE typepieceid = :id',
['id' => $id],
),
];
}
}

View File

@@ -0,0 +1,295 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\EntityManagerInterface;
use SplObjectStorage;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Importe un référentiel de fournisseurs depuis un fichier JSON de la forme
* {"count": N, "data": [{"reference": "...", "name": "...", "categoriesStr": "a, b", "organizationsStr": "...", "phone": "..."}, ...]}.
*
* Règles : on garde l'existant. Si un fournisseur du fichier porte le même nom (insensible à la casse/aux espaces)
* qu'un fournisseur déjà en base, on le complète sans changer son id : on n'ajoute que les catégories et les
* téléphones manquants, on n'écrase ni ne supprime jamais rien.
*/
#[AsCommand(
name: 'app:import-fournisseurs',
description: 'Importe/complète les fournisseurs depuis un fichier JSON (customer.json par défaut). Dry-run par défaut : utiliser --force pour écrire.',
)]
class ImportFournisseursCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $em,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('file', InputArgument::OPTIONAL, 'Chemin du fichier JSON', 'customer.json')
->addOption('force', null, InputOption::VALUE_NONE, 'Écrit réellement en base (sinon dry-run)')
->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Ne traiter que les N premières entrées (debug)')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$write = (bool) $input->getOption('force');
$limit = null !== $input->getOption('limit') ? max(0, (int) $input->getOption('limit')) : null;
$path = (string) $input->getArgument('file');
if (!str_starts_with($path, '/')) {
$path = rtrim($this->projectDir, '/').'/'.$path;
}
if (!is_file($path) || !is_readable($path)) {
$io->error(sprintf('Fichier introuvable ou illisible : %s', $path));
return Command::FAILURE;
}
$raw = file_get_contents($path);
$decoded = json_decode((string) $raw, true);
if (!is_array($decoded) || !isset($decoded['data']) || !is_array($decoded['data'])) {
$io->error('JSON invalide : la clé "data" (tableau) est attendue.');
return Command::FAILURE;
}
/** @var array<int, array<string, mixed>> $rows */
$rows = $decoded['data'];
if (null !== $limit) {
$rows = array_slice($rows, 0, $limit);
}
$io->title('Import fournisseurs');
$io->writeln(sprintf('Fichier : <info>%s</info>', $path));
$io->writeln(sprintf('Entrées : <info>%d</info>', count($rows)));
$io->writeln($write ? '<comment>Mode écriture (--force)</comment>' : '<comment>Mode dry-run — aucune écriture. Ajouter --force pour appliquer.</comment>');
$io->newLine();
// --- Chargement des référentiels existants ---------------------------------
/** @var array<string, Constructeur> $constructeursByName */
$constructeursByName = [];
foreach ($this->em->getRepository(Constructeur::class)->findAll() as $c) {
$constructeursByName[$this->normalizeKey((string) $c->getName())] = $c;
}
/** @var array<string, ConstructeurCategorie> $categoriesByName */
$categoriesByName = [];
foreach ($this->em->getRepository(ConstructeurCategorie::class)->findAll() as $cat) {
$categoriesByName[$this->normalizeKey((string) $cat->getName())] = $cat;
}
// numéros et liens catégorie déjà présents, indexés par objet Constructeur
$seenNumeros = new SplObjectStorage(); // Constructeur => array<string,true> (clé = numéro normalisé)
$seenCatLinks = new SplObjectStorage(); // Constructeur => array<string,true> (clé = nom catégorie normalisé)
// pré-remplissage pour les fournisseurs existants
$existingTel = $this->em->getRepository(ConstructeurTelephone::class)->findAll();
foreach ($existingTel as $tel) {
$owner = $tel->getConstructeur();
if (null === $owner) {
continue;
}
$map = $seenNumeros[$owner] ?? [];
$map[$this->normalizeKey((string) $tel->getNumero())] = true;
$seenNumeros[$owner] = $map;
}
/** @var array<int, array{cname: string, catname: string}> $catLinkPairs */
$catLinkPairs = $this->em->createQuery(
'SELECT c.name AS cname, cat.name AS catname FROM '.Constructeur::class.' c JOIN c.categories cat'
)->getArrayResult();
foreach ($catLinkPairs as $pair) {
$cKey = $this->normalizeKey((string) $pair['cname']);
$catKey = $this->normalizeKey((string) $pair['catname']);
$owner = $constructeursByName[$cKey] ?? null;
if (null === $owner) {
continue;
}
$map = $seenCatLinks[$owner] ?? [];
$map[$catKey] = true;
$seenCatLinks[$owner] = $map;
}
// --- Traitement ------------------------------------------------------------
$created = 0;
$matched = 0;
$phonesAdded = 0;
$categoriesCreated = 0;
$catLinksAdded = 0;
$skippedNoName = 0;
$tooLong = [];
$i = 0;
foreach ($rows as $row) {
++$i;
$name = trim((string) ($row['name'] ?? $row['reference'] ?? ''));
if ('' === $name) {
++$skippedNoName;
continue;
}
if (mb_strlen($name) > 255) {
$tooLong[] = $name;
$name = mb_substr($name, 0, 255);
}
$key = $this->normalizeKey($name);
if (isset($constructeursByName[$key])) {
$constructeur = $constructeursByName[$key];
++$matched;
} else {
$constructeur = new Constructeur()->setName($name);
if ($write) {
$this->em->persist($constructeur);
}
$constructeursByName[$key] = $constructeur;
++$created;
}
// --- téléphones ---
foreach ($this->splitPhones((string) ($row['phone'] ?? '')) as $numero) {
$numero = mb_substr($numero, 0, 50);
$nKey = $this->normalizeKey($numero);
$map = $seenNumeros[$constructeur] ?? [];
if (isset($map[$nKey])) {
continue;
}
$tel = new ConstructeurTelephone()->setNumero($numero);
$constructeur->addTelephone($tel);
if ($write) {
$this->em->persist($tel);
}
$map[$nKey] = true;
$seenNumeros[$constructeur] = $map;
++$phonesAdded;
}
// --- catégories ---
foreach ($this->splitCategories((string) ($row['categoriesStr'] ?? '')) as $catName) {
$catName = mb_substr($catName, 0, 255);
$catKey = $this->normalizeKey($catName);
if (isset($categoriesByName[$catKey])) {
$categorie = $categoriesByName[$catKey];
} else {
$categorie = new ConstructeurCategorie()->setName($catName);
if ($write) {
$this->em->persist($categorie);
}
$categoriesByName[$catKey] = $categorie;
++$categoriesCreated;
}
$linkMap = $seenCatLinks[$constructeur] ?? [];
if (isset($linkMap[$catKey])) {
continue;
}
$constructeur->addCategory($categorie);
$linkMap[$catKey] = true;
$seenCatLinks[$constructeur] = $linkMap;
++$catLinksAdded;
}
if ($write && 0 === $i % 200) {
$this->em->flush();
}
}
if ($write) {
$this->em->flush();
}
// --- Rapport ---------------------------------------------------------------
$io->section('Résultat');
$io->table(
['Action', 'Nombre'],
[
['Fournisseurs créés', $created],
['Fournisseurs déjà en base (complétés si besoin)', $matched],
['Téléphones ajoutés', $phonesAdded],
['Catégories créées', $categoriesCreated],
['Liens fournisseur↔catégorie ajoutés', $catLinksAdded],
['Entrées ignorées (sans nom)', $skippedNoName],
['Noms tronqués (>255)', count($tooLong)],
]
);
if ($tooLong) {
$io->warning(sprintf('%d nom(s) dépassaient 255 caractères et ont été tronqués.', count($tooLong)));
}
if ($write) {
$io->success('Import terminé.');
} else {
$io->note('Dry-run : rien n\'a été écrit. Relancer avec --force pour appliquer.');
}
return Command::SUCCESS;
}
/**
* @return list<string>
*/
private function splitPhones(string $value): array
{
$parts = preg_split('#[/;\n\r]+#', $value) ?: [];
$out = [];
foreach ($parts as $p) {
$p = trim($p);
if ('' !== $p) {
$out[] = $p;
}
}
return array_values(array_unique($out));
}
/**
* @return list<string>
*/
private function splitCategories(string $value): array
{
$parts = explode(',', $value);
$out = [];
$seen = [];
foreach ($parts as $p) {
$p = trim($p);
if ('' === $p) {
continue;
}
$k = $this->normalizeKey($p);
if (isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$out[] = $p;
}
return $out;
}
private function normalizeKey(string $value): string
{
return mb_strtolower(trim(preg_replace('/\s+/u', ' ', $value) ?? $value));
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[AsController]
final class CustomFieldNamesController
{
public function __construct(private readonly Connection $connection) {}
#[Route(
path: '/api/custom-fields/names',
name: 'api_custom_fields_names',
methods: ['GET']
)]
#[IsGranted('ROLE_VIEWER')]
public function __invoke(): JsonResponse
{
$sql = <<<'SQL'
SELECT DISTINCT name
FROM custom_fields
WHERE name IS NOT NULL AND name <> ''
ORDER BY name ASC
SQL;
$names = $this->connection->fetchFirstColumn($sql);
return new JsonResponse($names);
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller; namespace App\Controller;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField; use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Machine; use App\Entity\Machine;
@@ -162,9 +163,6 @@ class MachineStructureController extends AbstractController
// Copy product links // Copy product links
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap); $this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
// Copy context field values
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
$this->entityManager->flush(); $this->entityManager->flush();
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']); $componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
@@ -230,6 +228,17 @@ class MachineStructureController extends AbstractController
$newLink->setReferenceOverride($link->getReferenceOverride()); $newLink->setReferenceOverride($link->getReferenceOverride());
$newLink->setPrixOverride($link->getPrixOverride()); $newLink->setPrixOverride($link->getPrixOverride());
$this->entityManager->persist($newLink); $this->entityManager->persist($newLink);
foreach ($link->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
$newLink->getContextFieldValues()->add($newValue);
}
$linkMap[$link->getId()] = $newLink; $linkMap[$link->getId()] = $newLink;
} }
@@ -269,6 +278,17 @@ class MachineStructureController extends AbstractController
} }
$this->entityManager->persist($newLink); $this->entityManager->persist($newLink);
foreach ($link->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
$newLink->getContextFieldValues()->add($newValue);
}
$linkMap[$link->getId()] = $newLink; $linkMap[$link->getId()] = $newLink;
} }
@@ -317,45 +337,6 @@ class MachineStructureController extends AbstractController
} }
} }
/**
* @param array<string, MachineComponentLink> $componentLinkMap
* @param array<string, MachinePieceLink> $pieceLinkMap
*/
private function cloneContextFieldValues(
array $componentLinkMap,
array $pieceLinkMap,
): void {
foreach ($componentLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machineComponentLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachineComponentLink($newLink);
$newValue->setComposant($newLink->getComposant());
$this->entityManager->persist($newValue);
}
}
foreach ($pieceLinkMap as $oldLinkId => $newLink) {
$oldLink = $this->machinePieceLinkRepository->find($oldLinkId);
if (!$oldLink) {
continue;
}
foreach ($oldLink->getContextFieldValues() as $cfv) {
$newValue = new CustomFieldValue();
$newValue->setCustomField($cfv->getCustomField());
$newValue->setValue($cfv->getValue());
$newValue->setMachinePieceLink($newLink);
$newValue->setPiece($newLink->getPiece());
$this->entityManager->persist($newValue);
}
}
}
private function normalizePayloadList(mixed $value): array private function normalizePayloadList(mixed $value): array
{ {
if (!is_array($value)) { if (!is_array($value)) {
@@ -892,7 +873,7 @@ class MachineStructureController extends AbstractController
'id' => $link->getConstructeur()->getId(), 'id' => $link->getConstructeur()->getId(),
'name' => $link->getConstructeur()->getName(), 'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(), 'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(), 'phone' => $this->constructeurPhone($link->getConstructeur()),
], ],
'supplierReference' => $link->getSupplierReference(), 'supplierReference' => $link->getSupplierReference(),
]; ];
@@ -901,6 +882,13 @@ class MachineStructureController extends AbstractController
return $items; return $items;
} }
private function constructeurPhone(Constructeur $constructeur): ?string
{
$first = $constructeur->getTelephones()->first();
return false !== $first ? $first->getNumero() : null;
}
private function normalizeCustomFieldDefinitions(Collection $customFields): array private function normalizeCustomFieldDefinitions(Collection $customFields): array
{ {
$items = []; $items = [];

View File

@@ -4,6 +4,9 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -12,6 +15,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait; use App\Entity\Trait\CuidEntityTrait;
use App\Filter\ConstructeurSearchFilter;
use App\Repository\ConstructeurRepository; use App\Repository\ConstructeurRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
@@ -19,12 +23,16 @@ use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')] #[UniqueEntity(fields: ['name'], message: 'Un fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurRepository::class)] #[ORM\Entity(repositoryClass: ConstructeurRepository::class)]
#[ORM\Table(name: 'constructeurs')] #[ORM\Table(name: 'constructeurs')]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ApiFilter(ConstructeurSearchFilter::class)]
#[ApiFilter(SearchFilter::class, properties: ['categories.id' => 'exact'])]
#[ApiFilter(OrderFilter::class, properties: ['name', 'email', 'createdAt'])]
#[ApiResource( #[ApiResource(
description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.', description: 'Fournisseurs et constructeurs. Référentiel partagé entre les machines, pièces, composants et produits pour identifier les fabricants et distributeurs.',
operations: [ operations: [
@@ -36,7 +44,9 @@ use Symfony\Component\Validator\Constraints as Assert;
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"), new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
], ],
paginationClientItemsPerPage: true, paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 200 paginationMaximumItemsPerPage: 2000,
normalizationContext: ['groups' => ['constructeur:read']],
denormalizationContext: ['groups' => ['constructeur:write']]
)] )]
class Constructeur class Constructeur
{ {
@@ -44,24 +54,43 @@ class Constructeur
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)] #[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null; private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)] #[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')] #[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $name = null; private ?string $name = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)] #[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $email = null; private ?string $email = null;
#[ORM\Column(type: Types::STRING, length: 255, nullable: true)]
private ?string $phone = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
#[Groups(['constructeur:read'])]
private DateTimeImmutable $createdAt; private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
#[Groups(['constructeur:read'])]
private DateTimeImmutable $updatedAt; private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, ConstructeurTelephone>
*/
#[ORM\OneToMany(mappedBy: 'constructeur', targetEntity: ConstructeurTelephone::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private Collection $telephones;
/**
* @var Collection<int, ConstructeurCategorie>
*/
#[ORM\ManyToMany(targetEntity: ConstructeurCategorie::class, inversedBy: 'constructeurs')]
#[ORM\JoinTable(name: 'constructeur_categories')]
#[ORM\JoinColumn(name: 'constructeur_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[ORM\InverseJoinColumn(name: 'categorie_id', referencedColumnName: 'id', onDelete: 'CASCADE')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private Collection $categories;
/** /**
* @var Collection<int, MachineConstructeurLink> * @var Collection<int, MachineConstructeurLink>
*/ */
@@ -94,6 +123,8 @@ class Constructeur
$this->composantLinks = new ArrayCollection(); $this->composantLinks = new ArrayCollection();
$this->pieceLinks = new ArrayCollection(); $this->pieceLinks = new ArrayCollection();
$this->productLinks = new ArrayCollection(); $this->productLinks = new ArrayCollection();
$this->telephones = new ArrayCollection();
$this->categories = new ArrayCollection();
} }
public function getName(): ?string public function getName(): ?string
@@ -120,14 +151,55 @@ class Constructeur
return $this; return $this;
} }
public function getPhone(): ?string /**
* @return Collection<int, ConstructeurTelephone>
*/
public function getTelephones(): Collection
{ {
return $this->phone; return $this->telephones;
} }
public function setPhone(?string $phone): static public function addTelephone(ConstructeurTelephone $telephone): static
{ {
$this->phone = $phone; if (!$this->telephones->contains($telephone)) {
$this->telephones->add($telephone);
$telephone->setConstructeur($this);
}
return $this;
}
public function removeTelephone(ConstructeurTelephone $telephone): static
{
if ($this->telephones->removeElement($telephone)) {
if ($telephone->getConstructeur() === $this) {
$telephone->setConstructeur(null);
}
}
return $this;
}
/**
* @return Collection<int, ConstructeurCategorie>
*/
public function getCategories(): Collection
{
return $this->categories;
}
public function addCategory(ConstructeurCategorie $category): static
{
if (!$this->categories->contains($category)) {
$this->categories->add($category);
}
return $this;
}
public function removeCategory(ConstructeurCategorie $category): static
{
$this->categories->removeElement($category);
return $this; return $this;
} }

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurCategorieRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[UniqueEntity(fields: ['name'], message: 'Une catégorie de fournisseur avec ce nom existe déjà.')]
#[ORM\Entity(repositoryClass: ConstructeurCategorieRepository::class)]
#[ORM\Table(name: 'constructeur_categorie')]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Catégories de fournisseurs (ex. organisme de formation, transporteur, agence d\'intérim). Référentiel partagé : une même catégorie peut être rattachée à plusieurs fournisseurs.',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
],
paginationClientItemsPerPage: true,
paginationMaximumItemsPerPage: 1000,
order: ['name' => 'ASC']
)]
#[ApiFilter(SearchFilter::class, properties: ['name' => 'partial'])]
#[ApiFilter(OrderFilter::class, properties: ['name'])]
class ConstructeurCategorie
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null;
#[ORM\Column(type: Types::STRING, length: 255, unique: true)]
#[Assert\NotBlank(message: 'Le nom est obligatoire.')]
#[Groups(['constructeur:read'])]
private ?string $name = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
/**
* @var Collection<int, Constructeur>
*/
#[ORM\ManyToMany(targetEntity: Constructeur::class, mappedBy: 'categories')]
private Collection $constructeurs;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
$this->constructeurs = new ArrayCollection();
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use App\Entity\Trait\CuidEntityTrait;
use App\Repository\ConstructeurTelephoneRepository;
use DateTimeImmutable;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: ConstructeurTelephoneRepository::class)]
#[ORM\Table(name: 'constructeur_telephone')]
#[ORM\Index(name: 'idx_constructeur_telephone_constructeur', columns: ['constructeurid'])]
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
description: 'Numéro de téléphone rattaché à un fournisseur. Un fournisseur peut en avoir plusieurs (standard, mobile, comptabilité…).',
operations: [
new Get(security: "is_granted('ROLE_VIEWER')"),
new GetCollection(security: "is_granted('ROLE_VIEWER')"),
new Post(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Put(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Patch(security: "is_granted('ROLE_GESTIONNAIRE')"),
new Delete(security: "is_granted('ROLE_GESTIONNAIRE')"),
]
)]
#[ApiFilter(SearchFilter::class, properties: ['constructeur' => 'exact'])]
class ConstructeurTelephone
{
use CuidEntityTrait;
#[ORM\Id]
#[ORM\Column(type: Types::STRING, length: 36)]
#[Groups(['constructeur:read'])]
private ?string $id = null;
#[ORM\ManyToOne(targetEntity: Constructeur::class, inversedBy: 'telephones')]
#[ORM\JoinColumn(name: 'constructeurId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
private ?Constructeur $constructeur = null;
#[ORM\Column(type: Types::STRING, length: 50)]
#[Assert\NotBlank(message: 'Le numéro de téléphone est obligatoire.')]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $numero = null;
#[ORM\Column(type: Types::STRING, length: 100, nullable: true)]
#[Groups(['constructeur:read', 'constructeur:write'])]
private ?string $label = null;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
private DateTimeImmutable $createdAt;
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'updatedAt')]
private DateTimeImmutable $updatedAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->updatedAt = new DateTimeImmutable();
}
public function getConstructeur(): ?Constructeur
{
return $this->constructeur;
}
public function setConstructeur(?Constructeur $constructeur): static
{
$this->constructeur = $constructeur;
return $this;
}
public function getNumero(): ?string
{
return $this->numero;
}
public function setNumero(string $numero): static
{
$this->numero = $numero;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(?string $label): static
{
$this->label = $label;
return $this;
}
}

View File

@@ -11,8 +11,8 @@ use App\Entity\Machine;
use App\Entity\ModelType; use App\Entity\ModelType;
use App\Entity\Piece; use App\Entity\Piece;
use App\Entity\Product; use App\Entity\Product;
use App\Entity\Profile;
use App\Entity\Site; use App\Entity\Site;
use App\Service\ActorProfileResolver;
use BackedEnum; use BackedEnum;
use DateTimeInterface; use DateTimeInterface;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -22,10 +22,6 @@ use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
use Doctrine\ORM\UnitOfWork; use Doctrine\ORM\UnitOfWork;
use Error; use Error;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
use function is_array; use function is_array;
use function is_object; use function is_object;
@@ -35,8 +31,7 @@ use function method_exists;
abstract class AbstractAuditSubscriber implements EventSubscriber abstract class AbstractAuditSubscriber implements EventSubscriber
{ {
public function __construct( public function __construct(
private readonly RequestStack $requestStack, protected readonly ActorProfileResolver $actorProfileResolver,
private readonly Security $security,
) {} ) {}
public function getSubscribedEvents(): array public function getSubscribedEvents(): array
@@ -61,7 +56,7 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
} }
} }
$actorProfileId = $this->resolveActorProfileId(); $actorProfileId = $this->actorProfileResolver->resolve();
$entityType = $this->entityType(); $entityType = $this->entityType();
if ($this->hasCollectionTracking()) { if ($this->hasCollectionTracking()) {
@@ -278,28 +273,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
return $entity->getVersion(); return $entity->getVersion();
} }
protected function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void private function onFlushSimple(EntityManagerInterface $em, UnitOfWork $uow, ?string $actorProfileId, string $entityType): void
{ {
foreach ($uow->getScheduledEntityInsertions() as $entity) { foreach ($uow->getScheduledEntityInsertions() as $entity) {
@@ -385,13 +358,10 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
$this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId)); $this->persistAuditLog($em, new AuditLog($entityType, (string) $entity->getId(), 'delete', null, $snapshot, $actorProfileId));
} }
foreach ($uow->getScheduledCollectionUpdates() as $collection) { // Note: scheduled collection updates/deletions are intentionally not
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities); // tracked here — constructeurs are now persisted as ConstructeurLink
} // entities (OneToMany), so Doctrine no longer fires collection events
foreach ($uow->getScheduledCollectionDeletions() as $collection) { // for them. Custom field values are handled below.
$this->collectCollectionUpdate($collection, $pendingUpdates, $pendingSnapshots, $pendingEntities);
}
$this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities); $this->collectCustomFieldValueChanges($uow, $pendingUpdates, $pendingSnapshots, $pendingEntities);
foreach ($pendingUpdates as $entityId => $diff) { foreach ($pendingUpdates as $entityId => $diff) {
@@ -411,17 +381,6 @@ abstract class AbstractAuditSubscriber implements EventSubscriber
} }
} }
/**
* No-op: constructeurs are now tracked as ConstructeurLink entities (OneToMany),
* so Doctrine no longer fires collection update events for them.
*/
private function collectCollectionUpdate(
object $collection,
array &$pendingUpdates,
array &$pendingSnapshots,
array &$pendingEntities,
): void {}
private function collectCustomFieldValueChanges( private function collectCustomFieldValueChanges(
UnitOfWork $uow, UnitOfWork $uow,
array &$pendingUpdates, array &$pendingUpdates,

View File

@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\EventSubscriber; namespace App\EventSubscriber;
use App\Entity\Constructeur; use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Events; use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::onFlush)] #[AsDoctrineListener(event: Events::onFlush)]
@@ -23,11 +26,21 @@ final class ConstructeurAuditSubscriber extends AbstractAuditSubscriber
protected function snapshotEntity(object $entity): array protected function snapshotEntity(object $entity): array
{ {
$telephones = $this->safeGet($entity, 'getTelephones');
$categories = $this->safeGet($entity, 'getCategories');
return [ return [
'id' => $entity->getId(), 'id' => $entity->getId(),
'name' => $this->safeGet($entity, 'getName'), 'name' => $this->safeGet($entity, 'getName'),
'email' => $this->safeGet($entity, 'getEmail'), 'email' => $this->safeGet($entity, 'getEmail'),
'phone' => $this->safeGet($entity, 'getPhone'), 'telephones' => $telephones instanceof Collection ? array_values(array_map(
static fn (ConstructeurTelephone $t): array => ['numero' => $t->getNumero(), 'label' => $t->getLabel()],
$telephones->toArray(),
)) : [],
'categories' => $categories instanceof Collection ? array_values(array_filter(array_map(
static fn (ConstructeurCategorie $c): ?string => $c->getName(),
$categories->toArray(),
))) : [],
]; ];
} }
} }

View File

@@ -31,7 +31,7 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
} }
$uow = $em->getUnitOfWork(); $uow = $em->getUnitOfWork();
$actorProfileId = $this->resolveActorProfileId(); $actorProfileId = $this->actorProfileResolver->resolve();
$this->processLinkChanges($em, $uow, $actorProfileId); $this->processLinkChanges($em, $uow, $actorProfileId);
} }

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use App\Entity\ModelType;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Doctrine\ORM\Events;
use function sprintf;
/**
* Belt-and-suspenders cleanup of weak references to a ModelType before deletion:
* runs the equivalent of every "ON DELETE SET NULL" cascade applicatively, in case
* the database FK fails to fire (observed on prod in 2026-04 — the deletion of
* ModelType "Paliers" left an orphan in skeleton_subcomponent_requirements).
*/
#[AsDoctrineListener(event: Events::preRemove)]
final class ModelTypeReferenceCleanupSubscriber
{
/** @var list<array{0: string, 1: string}> */
private const NULLABLE_REFERENCES = [
['skeleton_subcomponent_requirements', 'typecomposantid'],
['skeleton_piece_requirements', 'typepieceid'],
['skeleton_product_requirements', 'typeproductid'],
['composant_piece_slots', 'typepieceid'],
['composant_product_slots', 'typeproductid'],
['composant_subcomponent_slots', 'typecomposantid'],
['piece_product_slots', 'typeproductid'],
['machine_component_links', 'modeltypeid'],
['machine_piece_links', 'modeltypeid'],
['machine_product_links', 'modeltypeid'],
];
public function preRemove(PreRemoveEventArgs $args): void
{
$entity = $args->getObject();
if (!$entity instanceof ModelType) {
return;
}
$id = $entity->getId();
if (!$id) {
return;
}
$conn = $args->getObjectManager()->getConnection();
foreach (self::NULLABLE_REFERENCES as [$table, $column]) {
$conn->executeStatement(
sprintf('UPDATE %s SET %s = NULL WHERE %s = ?', $table, $column, $column),
[$id],
);
}
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Filter;
use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\ConstructeurTelephone;
use Doctrine\ORM\QueryBuilder;
/**
* Search filter pour Constructeur : LIKE insensible à la casse sur name, email
* + LEFT JOIN sur la collection telephones pour matcher aussi sur telephone.numero.
* Param query : ?search=...
*/
final class ConstructeurSearchFilter extends AbstractFilter
{
public function getDescription(string $resourceClass): array
{
return [
'search' => [
'property' => null,
'type' => 'string',
'required' => false,
'description' => 'Recherche dans le nom, l\'email et les numéros de téléphone du fournisseur.',
'openapi' => [
'allowEmptyValue' => true,
],
],
];
}
protected function filterProperty(string $property, mixed $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void
{
if ('search' !== $property || !is_string($value) || '' === trim($value)) {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
$telAlias = $queryNameGenerator->generateJoinAlias('phoneSearch');
$paramName = $queryNameGenerator->generateParameterName('search');
$likePattern = '%'.mb_strtolower(trim($value)).'%';
$em = $queryBuilder->getEntityManager();
$phoneSubQuery = $em->createQueryBuilder()
->select('1')
->from(ConstructeurTelephone::class, $telAlias)
->where(sprintf('%1$s.constructeur = %2$s', $telAlias, $alias))
->andWhere(sprintf('LOWER(%s.numero) LIKE :%s', $telAlias, $paramName))
->getDQL()
;
$queryBuilder
->andWhere(sprintf(
'LOWER(%1$s.name) LIKE :%2$s OR LOWER(%1$s.email) LIKE :%2$s OR EXISTS (%3$s)',
$alias,
$paramName,
$phoneSubQuery,
))
->setParameter($paramName, $likePattern)
;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur; namespace App\Mcp\Tool\Constructeur;
use App\Entity\Constructeur; use App\Entity\Constructeur;
use App\Entity\ConstructeurTelephone;
use App\Mcp\Tool\McpToolHelper; use App\Mcp\Tool\McpToolHelper;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Mcp\Capability\Attribute\McpTool; use Mcp\Capability\Attribute\McpTool;
@@ -34,7 +35,12 @@ class CreateConstructeurTool
$constructeur = new Constructeur(); $constructeur = new Constructeur();
$constructeur->setName($name); $constructeur->setName($name);
$constructeur->setEmail('' !== $email ? $email : null); $constructeur->setEmail('' !== $email ? $email : null);
$constructeur->setPhone('' !== $phone ? $phone : null);
if ('' !== $phone) {
$telephone = new ConstructeurTelephone();
$telephone->setNumero($phone);
$constructeur->addTelephone($telephone);
}
$this->em->persist($constructeur); $this->em->persist($constructeur);
$this->em->flush(); $this->em->flush();

View File

@@ -29,13 +29,23 @@ class GetConstructeurTool
$this->mcpError('not_found', "Constructeur not found: {$constructeurId}"); $this->mcpError('not_found', "Constructeur not found: {$constructeurId}");
} }
$telephones = array_map(
static fn ($t): array => ['id' => $t->getId(), 'numero' => $t->getNumero(), 'label' => $t->getLabel()],
$constructeur->getTelephones()->toArray(),
);
$categories = array_values(array_filter(array_map(
static fn ($c): ?string => $c->getName(),
$constructeur->getCategories()->toArray(),
)));
return $this->jsonResponse([ return $this->jsonResponse([
'id' => $constructeur->getId(), 'id' => $constructeur->getId(),
'name' => $constructeur->getName(), 'name' => $constructeur->getName(),
'email' => $constructeur->getEmail(), 'email' => $constructeur->getEmail(),
'phone' => $constructeur->getPhone(), 'telephones' => array_values($telephones),
'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'), 'categories' => $categories,
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'), 'createdAt' => $constructeur->getCreatedAt()->format('Y-m-d H:i:s'),
'updatedAt' => $constructeur->getUpdatedAt()->format('Y-m-d H:i:s'),
]); ]);
} }
} }

View File

@@ -30,7 +30,7 @@ class ListConstructeursTool
; ;
$qb = $this->constructeurs->createQueryBuilder('c') $qb = $this->constructeurs->createQueryBuilder('c')
->select('c.id', 'c.name', 'c.email', 'c.phone') ->select('c.id', 'c.name', 'c.email')
->orderBy('c.name', 'ASC') ->orderBy('c.name', 'ASC')
; ;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Constructeur; namespace App\Mcp\Tool\Constructeur;
use App\Entity\ConstructeurTelephone;
use App\Mcp\Tool\McpToolHelper; use App\Mcp\Tool\McpToolHelper;
use App\Repository\ConstructeurRepository; use App\Repository\ConstructeurRepository;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@@ -13,7 +14,7 @@ use Symfony\Bundle\SecurityBundle\Security;
#[McpTool( #[McpTool(
name: 'update_constructeur', name: 'update_constructeur',
description: 'Update an existing constructeur. Only provided fields are changed. Requires ROLE_GESTIONNAIRE.', description: 'Update an existing constructeur. Only provided fields are changed. A non-empty "phone" is added as an additional phone number if not already present (existing numbers are never removed). Requires ROLE_GESTIONNAIRE.',
)] )]
class UpdateConstructeurTool class UpdateConstructeurTool
{ {
@@ -45,8 +46,20 @@ class UpdateConstructeurTool
if (null !== $email) { if (null !== $email) {
$constructeur->setEmail($email); $constructeur->setEmail($email);
} }
if (null !== $phone) { if (null !== $phone && '' !== $phone) {
$constructeur->setPhone($phone); $alreadyPresent = false;
foreach ($constructeur->getTelephones() as $existing) {
if ($existing->getNumero() === $phone) {
$alreadyPresent = true;
break;
}
}
if (!$alreadyPresent) {
$telephone = new ConstructeurTelephone();
$telephone->setNumero($phone);
$constructeur->addTelephone($telephone);
}
} }
$this->em->flush(); $this->em->flush();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Machine; namespace App\Mcp\Tool\Machine;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\Constructeur;
use App\Entity\CustomField; use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Machine; use App\Entity\Machine;
@@ -364,7 +365,7 @@ class MachineStructureTool
'id' => $link->getConstructeur()->getId(), 'id' => $link->getConstructeur()->getId(),
'name' => $link->getConstructeur()->getName(), 'name' => $link->getConstructeur()->getName(),
'email' => $link->getConstructeur()->getEmail(), 'email' => $link->getConstructeur()->getEmail(),
'phone' => $link->getConstructeur()->getPhone(), 'phone' => $this->constructeurPhone($link->getConstructeur()),
], ],
'supplierReference' => $link->getSupplierReference(), 'supplierReference' => $link->getSupplierReference(),
]; ];
@@ -373,6 +374,13 @@ class MachineStructureTool
return $items; return $items;
} }
private function constructeurPhone(Constructeur $constructeur): ?string
{
$first = $constructeur->getTelephones()->first();
return false !== $first ? $first->getNumero() : null;
}
private function normalizeCustomFields(Collection $customFields): array private function normalizeCustomFields(Collection $customFields): array
{ {
$items = []; $items = [];

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ConstructeurCategorie;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ConstructeurCategorie>
*/
class ConstructeurCategorieRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ConstructeurCategorie::class);
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\ConstructeurTelephone;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ConstructeurTelephone>
*/
class ConstructeurTelephoneRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ConstructeurTelephone::class);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Profile;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
final class ActorProfileResolver
{
public function __construct(
private readonly RequestStack $requestStack,
private readonly Security $security,
) {}
public function resolve(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
// No session available (CLI context, etc.)
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
}

View File

@@ -34,7 +34,6 @@ use DateTimeInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use InvalidArgumentException; use InvalidArgumentException;
use LogicException; use LogicException;
use Symfony\Component\HttpFoundation\RequestStack;
use Throwable; use Throwable;
final class EntityVersionService final class EntityVersionService
@@ -56,7 +55,7 @@ final class EntityVersionService
public function __construct( public function __construct(
private readonly AuditLogRepository $auditLogs, private readonly AuditLogRepository $auditLogs,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly RequestStack $requestStack, private readonly ActorProfileResolver $actorProfileResolver,
private readonly MachineRepository $machines, private readonly MachineRepository $machines,
private readonly ComposantRepository $composants, private readonly ComposantRepository $composants,
private readonly PieceRepository $pieces, private readonly PieceRepository $pieces,
@@ -187,7 +186,7 @@ final class EntityVersionService
'restore', 'restore',
['restoredFromVersion' => $version, 'restoreMode' => $restoreMode], ['restoredFromVersion' => $version, 'restoreMode' => $restoreMode],
$this->buildCurrentSnapshot($entityType, $entity), $this->buildCurrentSnapshot($entityType, $entity),
$this->resolveActorProfileId(), $this->actorProfileResolver->resolve(),
$newVersion, $newVersion,
); );
$this->em->persist($restoreAuditLog); $this->em->persist($restoreAuditLog);
@@ -917,25 +916,11 @@ final class EntityVersionService
'position' => $slot->getPosition(), 'position' => $slot->getPosition(),
]; ];
} }
$snapshot['productSlots'] = []; $snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
foreach ($entity->getProductSlots() as $slot) {
$snapshot['productSlots'][] = [
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
];
}
} }
if ('piece' === $entityType) { if ('piece' === $entityType) {
$snapshot['productSlots'] = []; $snapshot['productSlots'] = $this->serializeProductSlots($entity->getProductSlots());
foreach ($entity->getProductSlots() as $slot) {
$snapshot['productSlots'][] = [
'id' => $slot->getId(), 'typeProductId' => $slot->getTypeProduct()?->getId(),
'selectedProductId' => $slot->getSelectedProduct()?->getId(),
'familyCode' => $slot->getFamilyCode(), 'position' => $slot->getPosition(),
];
}
} }
// Custom field values // Custom field values
@@ -953,21 +938,23 @@ final class EntityVersionService
} }
/** /**
* Resolve the current actor profile ID from the session. * @param iterable<ComposantProductSlot|PieceProductSlot> $slots
* Mirrors AbstractAuditSubscriber::resolveActorProfileId(). *
* @return list<array<string, mixed>>
*/ */
private function resolveActorProfileId(): ?string private function serializeProductSlots(iterable $slots): array
{ {
try { $serialized = [];
$session = $this->requestStack->getSession(); foreach ($slots as $slot) {
$profileId = $session->get('profileId'); $serialized[] = [
if ($profileId) { 'id' => $slot->getId(),
return (string) $profileId; 'typeProductId' => $slot->getTypeProduct()?->getId(),
} 'selectedProductId' => $slot->getSelectedProduct()?->getId(),
} catch (Throwable) { 'familyCode' => $slot->getFamilyCode(),
// No session available (CLI context, etc.) 'position' => $slot->getPosition(),
];
} }
return null; return $serialized;
} }
} }

View File

@@ -4,23 +4,17 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Entity\Profile;
use App\Enum\ModelCategory; use App\Enum\ModelCategory;
use App\Repository\ModelTypeRepository; use App\Repository\ModelTypeRepository;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Throwable;
final class ModelTypeCategoryConversionService final class ModelTypeCategoryConversionService
{ {
public function __construct( public function __construct(
private readonly Connection $connection, private readonly Connection $connection,
private readonly ModelTypeRepository $modelTypes, private readonly ModelTypeRepository $modelTypes,
private readonly RequestStack $requestStack, private readonly ActorProfileResolver $actorProfileResolver,
private readonly Security $security,
) {} ) {}
/** /**
@@ -327,17 +321,7 @@ final class ModelTypeCategoryConversionService
); );
// 7. Update ModelType // 7. Update ModelType
$this->connection->executeStatement( $this->updateModelTypeCategory($modelTypeId, ModelCategory::COMPONENT);
'UPDATE model_types
SET category = :cat,
updatedat = :now
WHERE id = :id',
[
'cat' => ModelCategory::COMPONENT->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId,
],
);
return $count; return $count;
} }
@@ -406,19 +390,24 @@ final class ModelTypeCategoryConversionService
); );
// 7. Update ModelType // 7. Update ModelType
$this->updateModelTypeCategory($modelTypeId, ModelCategory::PIECE);
return $count;
}
private function updateModelTypeCategory(string $modelTypeId, ModelCategory $category): void
{
$this->connection->executeStatement( $this->connection->executeStatement(
'UPDATE model_types 'UPDATE model_types
SET category = :cat, SET category = :cat,
updatedat = :now updatedat = :now
WHERE id = :id', WHERE id = :id',
[ [
'cat' => ModelCategory::PIECE->value, 'cat' => $category->value,
'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'), 'now' => new DateTimeImmutable()->format('Y-m-d H:i:s'),
'id' => $modelTypeId, 'id' => $modelTypeId,
], ],
); );
return $count;
} }
/** /**
@@ -457,30 +446,9 @@ final class ModelTypeCategoryConversionService
'action' => 'convert', 'action' => 'convert',
'diff' => json_encode($diff), 'diff' => json_encode($diff),
'snapshot' => json_encode($snapshot), 'snapshot' => json_encode($snapshot),
'actor' => $this->resolveActorProfileId(), 'actor' => $this->actorProfileResolver->resolve(),
'now' => $now, 'now' => $now,
], ],
); );
} }
private function resolveActorProfileId(): ?string
{
try {
$session = $this->requestStack->getSession();
if ($session instanceof SessionInterface) {
$profileId = $session->get('profileId');
if ($profileId) {
return (string) $profileId;
}
}
} catch (Throwable) {
}
$user = $this->security->getUser();
if ($user instanceof Profile) {
return $user->getId();
}
return null;
}
} }

View File

@@ -32,7 +32,7 @@ class ReferenceAutoGenerator
} }
} }
return preg_replace_callback('/\{(\w+)\}/', static function (array $matches) use ($valueMap): string { return preg_replace_callback('/\{([^}]+)\}/u', static function (array $matches) use ($valueMap): string {
return $valueMap[$matches[1]] ?? ''; return $valueMap[$matches[1]] ?? '';
}, $modelType->getReferenceFormula()); }, $modelType->getReferenceFormula());
} }

View File

@@ -226,6 +226,13 @@ class SkeletonStructureService
} }
if ($existingField) { if ($existingField) {
// Propagate rename to the parent ModelType's reference formula and required-fields list
// so existing `{oldName}` placeholders keep resolving after the field is renamed.
$oldName = $existingField->getName();
if ($oldName !== $normalized['name']) {
$this->propagateCustomFieldRename($modelType, $oldName, $normalized['name']);
}
// Update existing field // Update existing field
$existingField->setName($normalized['name']); $existingField->setName($normalized['name']);
$existingField->setType($normalized['type']); $existingField->setType($normalized['type']);
@@ -264,6 +271,38 @@ class SkeletonStructureService
} }
} }
private function propagateCustomFieldRename(ModelType $modelType, string $oldName, string $newName): void
{
$formula = $modelType->getReferenceFormula();
if (null !== $formula && '' !== $formula) {
$newFormula = preg_replace(
'/\{'.preg_quote($oldName, '/').'\}/',
'{'.$newName.'}',
$formula
);
if (null !== $newFormula && $newFormula !== $formula) {
$modelType->setReferenceFormula($newFormula);
}
}
$required = $modelType->getRequiredFieldsForReference();
if ($required) {
$changed = false;
$newRequired = [];
foreach ($required as $fieldName) {
if ($fieldName === $oldName) {
$newRequired[] = $newName;
$changed = true;
} else {
$newRequired[] = $fieldName;
}
}
if ($changed) {
$modelType->setRequiredFieldsForReference($newRequired);
}
}
}
/** /**
* Normalize frontend custom field data to a common shape. * Normalize frontend custom field data to a common shape.
* *

View File

@@ -157,6 +157,18 @@
"symfony/mcp-bundle": { "symfony/mcp-bundle": {
"version": "v0.6.0" "version": "v0.6.0"
}, },
"symfony/monolog-bundle": {
"version": "4.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "3.7",
"ref": "1b9efb10c54cb51c713a9391c9300ff8bceda459"
},
"files": [
"config/packages/monolog.yaml"
]
},
"symfony/property-info": { "symfony/property-info": {
"version": "8.0", "version": "8.0",
"recipe": { "recipe": {

View File

@@ -7,11 +7,13 @@ namespace App\Tests;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Symfony\Bundle\Test\Client; use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Entity\Composant; use App\Entity\Composant;
use App\Entity\ComposantConstructeurLink;
use App\Entity\ComposantPieceSlot; use App\Entity\ComposantPieceSlot;
use App\Entity\ComposantProductSlot; use App\Entity\ComposantProductSlot;
use App\Entity\ComposantSubcomponentSlot; use App\Entity\ComposantSubcomponentSlot;
use App\Entity\ComposantConstructeurLink;
use App\Entity\Constructeur; use App\Entity\Constructeur;
use App\Entity\ConstructeurCategorie;
use App\Entity\ConstructeurTelephone;
use App\Entity\CustomField; use App\Entity\CustomField;
use App\Entity\CustomFieldValue; use App\Entity\CustomFieldValue;
use App\Entity\Machine; use App\Entity\Machine;
@@ -250,7 +252,12 @@ abstract class AbstractApiTestCase extends ApiTestCase
$c = new Constructeur(); $c = new Constructeur();
$c->setName($name); $c->setName($name);
$c->setEmail($email); $c->setEmail($email);
$c->setPhone($phone);
if (null !== $phone) {
$tel = new ConstructeurTelephone();
$tel->setNumero($phone);
$c->addTelephone($tel);
}
$em = $this->getEntityManager(); $em = $this->getEntityManager();
$em->persist($c); $em->persist($c);
@@ -259,6 +266,32 @@ abstract class AbstractApiTestCase extends ApiTestCase
return $c; return $c;
} }
protected function createConstructeurCategorie(string $name = 'Catégorie Test'): ConstructeurCategorie
{
$categorie = new ConstructeurCategorie();
$categorie->setName($name);
$em = $this->getEntityManager();
$em->persist($categorie);
$em->flush();
return $categorie;
}
protected function createConstructeurTelephone(Constructeur $constructeur, string $numero = '0102030405', ?string $label = null): ConstructeurTelephone
{
$tel = new ConstructeurTelephone();
$tel->setConstructeur($constructeur);
$tel->setNumero($numero);
$tel->setLabel($label);
$em = $this->getEntityManager();
$em->persist($tel);
$em->flush();
return $tel;
}
protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink protected function createMachineConstructeurLink(Machine $machine, Constructeur $constructeur, ?string $supplierReference = null): MachineConstructeurLink
{ {
$link = new MachineConstructeurLink(); $link = new MachineConstructeurLink();
@@ -467,6 +500,14 @@ abstract class AbstractApiTestCase extends ApiTestCase
$em->persist($cfv); $em->persist($cfv);
$em->flush(); $em->flush();
// Keep inverse-side collections in sync so identity-mapped entities reflect the new CFV.
if (null !== $machineComponentLink && !$machineComponentLink->getContextFieldValues()->contains($cfv)) {
$machineComponentLink->getContextFieldValues()->add($cfv);
}
if (null !== $machinePieceLink && !$machinePieceLink->getContextFieldValues()->contains($cfv)) {
$machinePieceLink->getContextFieldValues()->add($cfv);
}
return $cfv; return $cfv;
} }

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Tests\Api\Controller;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class CustomFieldNamesControllerTest extends AbstractApiTestCase
{
public function testReturns401WhenUnauthenticated(): void
{
$client = $this->createUnauthenticatedClient();
$client->request('GET', '/api/custom-fields/names');
$this->assertResponseStatusCodeSame(401);
}
public function testReturnsArrayForAuthenticatedViewer(): void
{
$client = $this->createViewerClient();
$client->request('GET', '/api/custom-fields/names');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($data);
}
public function testReturnsDistinctSortedNames(): void
{
$machine1 = $this->createMachine('M1');
$this->createCustomField('Tension', 'text', $machine1);
$this->createCustomField('Numéro de série', 'text', $machine1);
$machine2 = $this->createMachine('M2');
$this->createCustomField('Tension', 'text', $machine2); // doublon
$client = $this->createViewerClient();
$client->request('GET', '/api/custom-fields/names');
$this->assertResponseIsSuccessful();
$data = json_decode($client->getResponse()->getContent(), true);
$this->assertContains('Tension', $data);
$this->assertContains('Numéro de série', $data);
// Pas de doublon
$this->assertSame(count(array_unique($data)), count($data));
// Tri alpha
$sorted = $data;
sort($sorted, SORT_STRING);
$this->assertSame($sorted, $data);
}
}

View File

@@ -33,9 +33,9 @@ class ConstructeurTest extends AbstractApiTestCase
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
$this->assertJsonContains([ $this->assertJsonContains([
'name' => 'Siemens', 'name' => 'Siemens',
'email' => 'contact@siemens.com', 'email' => 'contact@siemens.com',
'phone' => '+33123456789', 'telephones' => [['numero' => '+33123456789']],
]); ]);
} }
@@ -78,11 +78,32 @@ class ConstructeurTest extends AbstractApiTestCase
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [ $client->request('PATCH', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'], 'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['phone' => '+33987654321'], 'json' => ['email' => 'updated@siemens.com'],
]); ]);
$this->assertResponseIsSuccessful(); $this->assertResponseIsSuccessful();
$this->assertJsonContains(['phone' => '+33987654321']); $this->assertJsonContains(['email' => 'updated@siemens.com']);
}
public function testPatchCategories(): void
{
$c = $this->createConstructeur('Siemens');
$cat1 = $this->createConstructeurCategorie('Transporteur');
$cat2 = $this->createConstructeurCategorie('Organisme de formation');
$client = $this->createGestionnaireClient();
$client->request('PATCH', self::iri('constructeurs', $c->getId()), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['categories' => [
self::iri('constructeur_categories', $cat1->getId()),
self::iri('constructeur_categories', $cat2->getId()),
]],
]);
$this->assertResponseIsSuccessful();
$client->request('GET', self::iri('constructeurs', $c->getId()));
$this->assertResponseIsSuccessful();
$this->assertJsonContains(['categories' => [['name' => 'Transporteur'], ['name' => 'Organisme de formation']]]);
} }
public function testDelete(): void public function testDelete(): void

View File

@@ -7,6 +7,9 @@ namespace App\Tests\Api\Entity;
use App\Enum\ModelCategory; use App\Enum\ModelCategory;
use App\Tests\AbstractApiTestCase; use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class MachineContextCustomFieldTest extends AbstractApiTestCase class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
public function testStructureReturnsContextFieldsOnComponentLink(): void public function testStructureReturnsContextFieldsOnComponentLink(): void
@@ -56,7 +59,7 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
$normalFields = array_filter( $normalFields = array_filter(
$componentLink['composant']['customFields'], $componentLink['composant']['customFields'],
fn (array $f) => $f['name'] === 'Serial', fn (array $f) => 'Serial' === $f['name'],
); );
$this->assertCount(1, $normalFields); $this->assertCount(1, $normalFields);
} }
@@ -65,8 +68,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site B'); $site = $this->createSite('Site B');
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE); $modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'Wear Level', name: 'Wear Level',
type: 'select', type: 'select',
@@ -101,8 +104,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site C'); $site = $this->createSite('Site C');
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT); $modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'Flow Rate', name: 'Flow Rate',
type: 'number', type: 'number',
@@ -131,8 +134,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site D'); $site = $this->createSite('Site D');
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT); $modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'Pressure', name: 'Pressure',
type: 'number', type: 'number',
@@ -171,8 +174,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site E'); $site = $this->createSite('Site E');
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT); $modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'Calibration Date', name: 'Calibration Date',
type: 'date', type: 'date',
@@ -190,8 +193,8 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
{ {
$client = $this->createGestionnaireClient(); $client = $this->createGestionnaireClient();
$site = $this->createSite('Site F'); $site = $this->createSite('Site F');
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT); $modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
$contextField = $this->createCustomField( $contextField = $this->createCustomField(
name: 'RPM Setting', name: 'RPM Setting',
type: 'number', type: 'number',
@@ -225,4 +228,84 @@ class MachineContextCustomFieldTest extends AbstractApiTestCase
$this->assertCount(1, $clonedLink['contextCustomFieldValues']); $this->assertCount(1, $clonedLink['contextCustomFieldValues']);
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']); $this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
} }
public function testCloneMachineCopiesPieceContextFieldValues(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site G');
$modelType = $this->createModelType('Bearing Clone', 'BRGC', ModelCategory::PIECE);
$contextField = $this->createCustomField(
name: 'Wear Level',
type: 'text',
typePiece: $modelType,
machineContextOnly: true,
);
$source = $this->createMachine('Source Piece Machine', $site);
$piece = $this->createPiece('Bearing C', 'BRGC-001', $modelType);
$link = $this->createMachinePieceLink($source, $piece);
$this->createCustomFieldValue(
customField: $contextField,
value: 'Fair',
piece: $piece,
machinePieceLink: $link,
);
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
'json' => [
'name' => 'Cloned Piece Machine',
'siteId' => $site->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
$data = $response->toArray();
$clonedLink = $data['pieceLinks'][0] ?? null;
$this->assertNotNull($clonedLink, 'Clone should expose at least one pieceLink');
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
$this->assertSame('Fair', $clonedLink['contextCustomFieldValues'][0]['value']);
}
public function testCloneMachineLeavesSourceContextFieldValuesIntact(): void
{
$client = $this->createGestionnaireClient();
$site = $this->createSite('Site H');
$modelType = $this->createModelType('Motor Source', 'MOTS', ModelCategory::COMPONENT);
$contextField = $this->createCustomField(
name: 'RPM',
type: 'number',
typeComposant: $modelType,
machineContextOnly: true,
);
$source = $this->createMachine('Original Machine', $site);
$composant = $this->createComposant('Motor S', 'MOTS-001', $modelType);
$link = $this->createMachineComponentLink($source, $composant);
$this->createCustomFieldValue(
customField: $contextField,
value: '1500',
composant: $composant,
machineComponentLink: $link,
);
$client->request('POST', '/api/machines/'.$source->getId().'/clone', [
'json' => [
'name' => 'Clone Machine',
'siteId' => $site->getId(),
],
]);
$this->assertResponseStatusCodeSame(201);
// Source must still expose its original context field value
$sourceData = $client->request('GET', '/api/machines/'.$source->getId().'/structure')->toArray();
$sourceLink = $sourceData['componentLinks'][0] ?? null;
$this->assertNotNull($sourceLink, 'Source machine should still expose its component link');
$this->assertCount(1, $sourceLink['contextCustomFieldValues']);
$this->assertSame('1500', $sourceLink['contextCustomFieldValues'][0]['value']);
}
} }

View File

@@ -47,7 +47,7 @@ class SessionProfileTest extends AbstractApiTestCase
]); ]);
$this->assertResponseStatusCodeSame(401); $this->assertResponseStatusCodeSame(401);
$this->assertJsonContains(['message' => 'Mot de passe incorrect.']); $this->assertJsonContains(['message' => 'Identifiants invalides.']);
} }
public function testLoginMissingPassword(): void public function testLoginMissingPassword(): void
@@ -103,7 +103,7 @@ class SessionProfileTest extends AbstractApiTestCase
], ],
]); ]);
$this->assertResponseStatusCodeSame(403); $this->assertResponseStatusCodeSame(401);
} }
public function testGetActiveProfileAuthenticated(): void public function testGetActiveProfileAuthenticated(): void

View File

@@ -145,6 +145,69 @@ class ReferenceAutoGeneratorTest extends AbstractApiTestCase
self::assertSame('U507', $result); self::assertSame('U507', $result);
} }
public function testGenerateWithAccentedFieldName(): void
{
$mt = $this->createModelType('Palier', 'PAL-ACCENT', ModelCategory::PIECE);
$mt->setReferenceFormula('PA-{Diamètre}-33');
$mt->setRequiredFieldsForReference(['Diamètre']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('Diamètre', 'number', typePiece: $mt);
$piece = $this->createPiece('Palier 70', null, $mt);
$this->createCustomFieldValue($cf, '70', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('PA-70-33', $result);
}
public function testGenerateWithNumberTypeField(): void
{
$mt = $this->createModelType('NumberField', 'NUM-001', ModelCategory::PIECE);
$mt->setReferenceFormula('R-{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
$piece = $this->createPiece('Piece Number', null, $mt);
$this->createCustomFieldValue($cf, '42', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('R-42', $result);
}
public function testGenerateWithDecimalNumberField(): void
{
$mt = $this->createModelType('NumberDec', 'NUM-002', ModelCategory::PIECE);
$mt->setReferenceFormula('R-{taille}');
$mt->setRequiredFieldsForReference(['taille']);
$em = $this->getEntityManager();
$em->flush();
$cf = $this->createCustomField('taille', 'number', typePiece: $mt);
$piece = $this->createPiece('Piece Dec', null, $mt);
$this->createCustomFieldValue($cf, '12.5', piece: $piece);
$em->refresh($piece);
$generator = self::getContainer()->get('App\Service\ReferenceAutoGenerator');
$result = $generator->generate($piece);
self::assertSame('R-12.5', $result);
}
public function testGenerateWithSpaceInFormula(): void public function testGenerateWithSpaceInFormula(): void
{ {
$mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE); $mt = $this->createModelType('Palier2', 'PAL-002', ModelCategory::PIECE);

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\ModelCategory;
use App\Service\SkeletonStructureService;
use App\Tests\AbstractApiTestCase;
/**
* @internal
*/
class SkeletonStructureServiceTest extends AbstractApiTestCase
{
public function testRenameCustomFieldPropagatesToReferenceFormulaAndRequiredFields(): void
{
$mt = $this->createModelType('Roulement', 'ROUL-RENAME', ModelCategory::PIECE);
$mt->setReferenceFormula('{material}-{size}');
$mt->setRequiredFieldsForReference(['material', 'size']);
$em = $this->getEntityManager();
$em->flush();
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
$cfSize = $this->createCustomField('size', 'text', typePiece: $mt, orderIndex: 1);
/** @var SkeletonStructureService $service */
$service = static::getContainer()->get(SkeletonStructureService::class);
// Same fields, but `material` is renamed to `materiau` (matched by customFieldId)
$service->updateSkeletonRequirements($mt, [
'customFields' => [
['customFieldId' => $cfMaterial->getId(), 'name' => 'materiau', 'type' => 'text', 'orderIndex' => 0],
['customFieldId' => $cfSize->getId(), 'name' => 'size', 'type' => 'text', 'orderIndex' => 1],
],
]);
$em->flush();
$em->refresh($mt);
$em->refresh($cfMaterial);
self::assertSame('materiau', $cfMaterial->getName());
self::assertSame('{materiau}-{size}', $mt->getReferenceFormula());
self::assertSame(['materiau', 'size'], $mt->getRequiredFieldsForReference());
}
public function testRenameLeavesFormulaUnchangedWhenFieldNotInFormula(): void
{
$mt = $this->createModelType('Roulement2', 'ROUL-RENAME2', ModelCategory::PIECE);
$mt->setReferenceFormula('{material}');
$mt->setRequiredFieldsForReference(['material']);
$em = $this->getEntityManager();
$em->flush();
$cfMaterial = $this->createCustomField('material', 'text', typePiece: $mt, orderIndex: 0);
$cfUnused = $this->createCustomField('unused', 'text', typePiece: $mt, orderIndex: 1);
/** @var SkeletonStructureService $service */
$service = static::getContainer()->get(SkeletonStructureService::class);
$service->updateSkeletonRequirements($mt, [
'customFields' => [
['customFieldId' => $cfMaterial->getId(), 'name' => 'material', 'type' => 'text', 'orderIndex' => 0],
['customFieldId' => $cfUnused->getId(), 'name' => 'renamed', 'type' => 'text', 'orderIndex' => 1],
],
]);
$em->flush();
$em->refresh($mt);
self::assertSame('{material}', $mt->getReferenceFormula());
self::assertSame(['material'], $mt->getRequiredFieldsForReference());
}
}