Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1705a3688b | ||
|
|
34b36f5d14 | ||
|
|
d6b74f01f9 | ||
|
|
5efedfabf8 | ||
|
|
d0aba111b3 | ||
| 6eaefdbbbf | |||
| b869984609 | |||
| 59fae38176 | |||
| a674a5f2f0 | |||
| 0049638e3c | |||
| c54e5c33f2 | |||
| 51b491097e | |||
| 7da78b3b3e | |||
| a88e4a68fb | |||
| 61584f2f9b | |||
| 970708ea83 | |||
| 7b674fcc0c | |||
| 99147f4e08 | |||
| aec33e7911 | |||
| 2edb748bd4 | |||
| da12955b52 | |||
| 1385d7768c | |||
| 500b6b1620 | |||
| 54203db328 | |||
| d085c48953 | |||
| 4f1f643436 | |||
| 1c3b566923 | |||
| 342ae37762 | |||
|
|
1529d21f12 | ||
|
|
d6441bef06 | ||
|
|
3cf9db8829 | ||
|
|
12c2b1e1b3 | ||
|
|
b92c09cf55 | ||
| 18cb9d5d80 | |||
|
|
4ba134dd69 | ||
|
|
5e7a744151 | ||
|
|
044b64152c | ||
|
|
4de3ffa0e0 | ||
|
|
5bdf578de9 | ||
|
|
bc1b757a96 | ||
|
|
24b664e85b | ||
|
|
8565e68062 | ||
|
|
a8a95b16a9 | ||
|
|
68b394fc14 | ||
|
|
2ceb49db9f | ||
|
|
8ad0f26249 | ||
|
|
be859e57db |
@@ -2,10 +2,10 @@
|
||||
.gitea
|
||||
.env.local
|
||||
.env.test
|
||||
docker/
|
||||
deploy/docker/docker-compose.prod.yml
|
||||
deploy/docker/deploy.sh
|
||||
deploy/docker/.env.example
|
||||
infra/dev/
|
||||
infra/prod/docker-compose.yml
|
||||
infra/prod/deploy.sh
|
||||
infra/prod/.env.example
|
||||
frontend/node_modules
|
||||
frontend/.nuxt
|
||||
frontend/.output
|
||||
|
||||
65
.gitea/workflows/auto-tag-develop.yml
Normal file
65
.gitea/workflows/auto-tag-develop.yml
Normal file
@@ -0,0 +1,65 @@
|
||||
name: Auto Tag Develop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REGISTRY_TOKEN }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Create next tag from config/version.yaml
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Skip if current commit already has a vX.Y.Z tag
|
||||
if git tag --points-at HEAD | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "Tag already exists on this commit. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
changed_version=false
|
||||
if git diff --name-only "${{ gitea.event.before }}" "${{ gitea.event.after }}" | grep -q '^config/version\.yaml$'; then
|
||||
changed_version=true
|
||||
fi
|
||||
|
||||
read_version() {
|
||||
awk -F': *' '/app\.version:/{print $2}' config/version.yaml | tr -d '[:space:]' | tr -d "'\""
|
||||
}
|
||||
|
||||
if $changed_version; then
|
||||
version="$(read_version)"
|
||||
if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid version in version.yaml: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
last_tag="$(git tag -l 'v*' --sort=-v:refname | head -n1 || true)"
|
||||
if [ -z "$last_tag" ]; then
|
||||
version="0.1.0"
|
||||
else
|
||||
base="${last_tag#v}"
|
||||
IFS='.' read -r major minor patch <<< "$base"
|
||||
version="${major}.${minor}.$((patch + 1))"
|
||||
fi
|
||||
|
||||
printf "parameters:\\n app.version: '%s'\\n" "$version" > config/version.yaml
|
||||
git config user.name "gitea-actions"
|
||||
git config user.email "gitea-actions@local"
|
||||
git add config/version.yaml
|
||||
git commit -m "chore : bump version to v$version" || true
|
||||
git push origin develop || true
|
||||
fi
|
||||
|
||||
tag="v$version"
|
||||
git tag "$tag"
|
||||
git push origin "$tag"
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
-f deploy/docker/Dockerfile.prod \
|
||||
-f infra/prod/Dockerfile \
|
||||
-t gitea.malio.fr/malio-dev/inventory:${{ gitea.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/inventory:latest \
|
||||
.
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -20,7 +20,7 @@
|
||||
###< phpunit/phpunit ###
|
||||
|
||||
###> docker ###
|
||||
docker/.env.docker.local
|
||||
infra/dev/.env.docker.local
|
||||
###< docker ###
|
||||
###> migration archives ###
|
||||
/_archives/
|
||||
|
||||
12
DEPLOY.md
12
DEPLOY.md
@@ -12,7 +12,7 @@ inventory.malio-dev.fr/api → Backend Symfony (PHP-FPM derrière Nginx)
|
||||
| Composant | Technologie | Emplacement serveur |
|
||||
|-----------|-------------|---------------------|
|
||||
| Backend | Symfony 8 + API Platform | `/var/www/Inventory/` |
|
||||
| Frontend | Nuxt 4 (site statique) | `/var/www/Inventory/Inventory_frontend/.output/public/` |
|
||||
| Frontend | Nuxt 4 (site statique) | `/var/www/Inventory/frontend/.output/public/` |
|
||||
| Base de données | PostgreSQL 16 | Base `inventory` |
|
||||
|
||||
### Schéma simplifié
|
||||
@@ -117,7 +117,7 @@ php bin/console doctrine:migrations:migrate --no-interaction
|
||||
### 4. Configurer le frontend Nuxt
|
||||
|
||||
```bash
|
||||
cd /var/www/Inventory/Inventory_frontend
|
||||
cd /var/www/Inventory/frontend
|
||||
|
||||
# Permissions
|
||||
sudo chown -R malio:malio .
|
||||
@@ -173,7 +173,7 @@ server {
|
||||
|
||||
# Frontend statique — tout le reste
|
||||
location / {
|
||||
root /var/www/Inventory/Inventory_frontend/.output/public;
|
||||
root /var/www/Inventory/frontend/.output/public;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html; # SPA fallback
|
||||
}
|
||||
@@ -214,7 +214,7 @@ php bin/console cache:clear --env=prod
|
||||
sudo chown -R www-data:www-data var/
|
||||
|
||||
# Frontend
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
npm install
|
||||
npx nuxi generate
|
||||
```
|
||||
@@ -268,7 +268,7 @@ php /var/www/Inventory/bin/console cache:clear --env=prod
|
||||
|
||||
Les fichiers statiques sont en cache. Rebuilder :
|
||||
```bash
|
||||
cd /var/www/Inventory/Inventory_frontend
|
||||
cd /var/www/Inventory/frontend
|
||||
rm -rf .output
|
||||
npx nuxi generate
|
||||
```
|
||||
@@ -299,7 +299,7 @@ tail -f /var/www/Inventory/var/log/prod.log
|
||||
php /var/www/Inventory/bin/console cache:clear --env=prod
|
||||
|
||||
# Rebuild frontend
|
||||
cd /var/www/Inventory/Inventory_frontend && npx nuxi generate
|
||||
cd /var/www/Inventory/frontend && npx nuxi generate
|
||||
|
||||
# Status des services
|
||||
systemctl status php8.4-fpm
|
||||
|
||||
@@ -57,7 +57,7 @@ make start
|
||||
make install
|
||||
```
|
||||
|
||||
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `docker/.env.docker.local`.
|
||||
> Si `make start` échoue sur le port de la BDD, modifier `POSTGRES_PORT` dans `infra/dev/.env.docker.local`.
|
||||
|
||||
### Que fait `make install` ?
|
||||
|
||||
@@ -254,7 +254,7 @@ Configuration PhpStorm / VSCode :
|
||||
- **Port** : `8081`
|
||||
- **Path mapping** : racine du projet → `/var/www/html`
|
||||
|
||||
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `docker/.env.docker.local` avec votre IP locale.
|
||||
> Sous WSL, modifier `XDEBUG_CLIENT_HOST` dans `infra/dev/.env.docker.local` avec votre IP locale.
|
||||
|
||||
## Git
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ Après avoir exécuté le script :
|
||||
|
||||
```bash
|
||||
# Pousser le frontend d'abord (si modifié)
|
||||
cd Inventory_frontend && git push && git push --tags && cd ..
|
||||
cd frontend && git push && git push --tags && cd ..
|
||||
|
||||
# Pousser le backend
|
||||
git push && git push --tags
|
||||
@@ -79,7 +79,7 @@ git push && git push --tags
|
||||
|---------|------|
|
||||
| `VERSION` | Source unique de vérité |
|
||||
| `config/packages/api_platform.yaml` | Version affichée dans la doc API (Swagger) |
|
||||
| `Inventory_frontend/nuxt.config.ts` | Lit `VERSION` au build pour l'afficher dans le footer |
|
||||
| `frontend/nuxt.config.ts` | Lit `VERSION` au build pour l'afficher dans le footer |
|
||||
| Footer de l'app | Affiche `v{{ appVersion }}` |
|
||||
|
||||
## Notes de release
|
||||
@@ -118,5 +118,5 @@ git submodule update --init --recursive
|
||||
composer install --no-dev --optimize-autoloader
|
||||
php bin/console doctrine:migrations:migrate --no-interaction
|
||||
php bin/console cache:clear --env=prod
|
||||
cd Inventory_frontend && npm install && npx nuxi generate
|
||||
cd frontend && npm install && npx nuxi generate
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
api_platform:
|
||||
title: Inventory API
|
||||
description: API de gestion d'inventaire industriel — machines, pièces, composants, produits.
|
||||
version: 1.9.1
|
||||
version: 1.9.6
|
||||
defaults:
|
||||
stateless: false
|
||||
cache_headers:
|
||||
|
||||
@@ -55,6 +55,7 @@ security:
|
||||
- { path: ^/api/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/api/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/health$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/api/maintenance/check$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/_mcp, roles: ROLE_USER }
|
||||
- { path: ^/docs, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/contexts, roles: PUBLIC_ACCESS }
|
||||
|
||||
2
config/version.yaml
Normal file
2
config/version.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.14'
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export INVENTORY_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying inventory:${TAG}..."
|
||||
|
||||
echo "==> Pulling image..."
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat VERSION)
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -1,13 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name inventory.malio-dev.fr;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ services:
|
||||
web:
|
||||
container_name: php-${DOCKER_APP_NAME}-apache
|
||||
build:
|
||||
context: ./docker/php
|
||||
context: ./infra/dev
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
DOCKER_PHP_VERSION: ${DOCKER_PHP_VERSION}
|
||||
@@ -20,9 +20,9 @@ services:
|
||||
- ~/.cache:/var/www/.cache # Pour la cache de composer
|
||||
- ~/.config:/var/www/.config # Pour la config de yarn
|
||||
- ~/.composer:/var/www/.composer # Pour la config de composer
|
||||
- ./docker/php/config/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./docker/php/config/vhost.conf:/etc/apache2/sites-available/000-default.conf
|
||||
- ./docker/php/config/docker-php-ext-xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./infra/dev/php.ini:/usr/local/etc/php/php.ini
|
||||
- ./infra/dev/vhost.conf:/etc/apache2/sites-available/000-default.conf
|
||||
- ./infra/dev/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
|
||||
- ./LOG:/var/www/html/LOG
|
||||
- ./LOG/logs_apache:/var/log/apache2/
|
||||
extra_hosts:
|
||||
|
||||
@@ -103,10 +103,10 @@ Cela securise l'integrite sans changer l'architecture. Le `resolveTarget` et les
|
||||
### 3. Composables frontend geants (400-550 LOC)
|
||||
|
||||
**Fichiers concernes :**
|
||||
- `/Inventory_frontend/app/composables/useComponentEdit.ts` (550 LOC)
|
||||
- `/Inventory_frontend/app/composables/usePieceEdit.ts` (472 LOC)
|
||||
- `/Inventory_frontend/app/composables/useMachineDetailData.ts` (468 LOC)
|
||||
- `/Inventory_frontend/app/composables/useComponentCreate.ts` (417 LOC)
|
||||
- `/frontend/app/composables/useComponentEdit.ts` (550 LOC)
|
||||
- `/frontend/app/composables/usePieceEdit.ts` (472 LOC)
|
||||
- `/frontend/app/composables/useMachineDetailData.ts` (468 LOC)
|
||||
- `/frontend/app/composables/useComponentCreate.ts` (417 LOC)
|
||||
|
||||
**Probleme :** Ces composables orchestrent en un seul fichier : le chargement de donnees, la gestion de formulaire, la persistence des custom fields, la gestion des documents, l'historique, la resolution de labels, et la soumission. Chacun instancie 8-12 sous-composables.
|
||||
|
||||
@@ -134,9 +134,9 @@ Appliquer le meme pattern a `usePieceEdit` et `useComponentCreate`. Les blocs co
|
||||
### 4. Triple duplication de la logique custom fields frontend
|
||||
|
||||
**Fichiers concernes :**
|
||||
- `/Inventory_frontend/app/shared/utils/customFieldFormUtils.ts` (404 LOC) - pour les pages create/edit
|
||||
- `/Inventory_frontend/app/shared/utils/customFieldUtils.ts` (440 LOC) - pour la page machine detail
|
||||
- `/Inventory_frontend/app/shared/utils/entityCustomFieldLogic.ts` (335 LOC) - pour les composants item
|
||||
- `/frontend/app/shared/utils/customFieldFormUtils.ts` (404 LOC) - pour les pages create/edit
|
||||
- `/frontend/app/shared/utils/customFieldUtils.ts` (440 LOC) - pour la page machine detail
|
||||
- `/frontend/app/shared/utils/entityCustomFieldLogic.ts` (335 LOC) - pour les composants item
|
||||
|
||||
**Probleme :** Ces 3 fichiers resolvent le meme probleme (normaliser des definitions de custom fields + merger avec des valeurs existantes) avec des implementations differentes :
|
||||
- `customFieldFormUtils.ts` : `resolveFieldName()`, `resolveFieldType()`, `buildCustomFieldInputs()`
|
||||
@@ -273,7 +273,7 @@ public function process(mixed $data, Operation $operation, ...): mixed
|
||||
### 9. Dependance circulaire dans `useMachineDetailData`
|
||||
|
||||
**Fichier concerne :**
|
||||
- `/Inventory_frontend/app/composables/useMachineDetailData.ts` (lignes 119-187)
|
||||
- `/frontend/app/composables/useMachineDetailData.ts` (lignes 119-187)
|
||||
|
||||
**Probleme :** `useMachineDetailProducts` a besoin de `machineProductLinks` (venant de hierarchy), et `useMachineDetailHierarchy` a besoin de `findProductById` (venant de products). La solution actuelle utilise un `_machineProductLinksProxy` ref avec un watcher pour synchroniser.
|
||||
|
||||
|
||||
@@ -811,9 +811,9 @@ private function resolvePieceQuantity(MachinePieceLink $pieceLink): int
|
||||
### Task 2.5: Update Frontend to Handle New Structure Format
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/useMachineHierarchy.ts` — `buildMachineHierarchyFromLinks()`
|
||||
- Modify: `Inventory_frontend/app/shared/utils/structureDisplayUtils.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/utils/structureSelectionUtils.ts`
|
||||
- Modify: `frontend/app/composables/useMachineHierarchy.ts` — `buildMachineHierarchyFromLinks()`
|
||||
- Modify: `frontend/app/shared/utils/structureDisplayUtils.ts`
|
||||
- Modify: `frontend/app/shared/utils/structureSelectionUtils.ts`
|
||||
|
||||
**Note:** The API response shape for `structure` stays the same (pieces/subcomponents/products arrays), so frontend changes should be minimal. The main change is that `path` fields are removed and `resolvedPiece` is now always populated inline.
|
||||
|
||||
@@ -950,7 +950,7 @@ ALTER TABLE pieces DROP COLUMN IF EXISTS productids;
|
||||
### Task 4.1: Update Frontend Types
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/shared/types/` (if type definitions reference structure/skeleton JSON shapes)
|
||||
- Modify: `frontend/app/shared/types/` (if type definitions reference structure/skeleton JSON shapes)
|
||||
|
||||
- [ ] **Step 1: Search frontend for all references to `structure.pieces`, `structure.subcomponents`, `structure.products`, `skeleton`, `productIds`**
|
||||
|
||||
|
||||
@@ -274,15 +274,15 @@ git commit -m "test(piece) : add quantity tests for MachinePieceLink"
|
||||
### Task 4: TypeScript Types + Sanitization + Hydration Functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/shared/types/inventory.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructure.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`
|
||||
- Modify: `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`
|
||||
- Modify: `frontend/app/shared/types/inventory.ts`
|
||||
- Modify: `frontend/app/shared/model/componentStructure.ts`
|
||||
- Modify: `frontend/app/shared/model/componentStructureSanitize.ts`
|
||||
- Modify: `frontend/app/shared/model/componentStructureHydrate.ts`
|
||||
- Modify: `frontend/app/shared/utils/structureAssignmentHelpers.ts`
|
||||
|
||||
- [ ] **Step 1: Add `quantity` to `ComponentModelPiece` type**
|
||||
|
||||
In `Inventory_frontend/app/shared/types/inventory.ts`, add `quantity` to the `ComponentModelPiece` interface (after `role`, line ~23):
|
||||
In `frontend/app/shared/types/inventory.ts`, add `quantity` to the `ComponentModelPiece` interface (after `role`, line ~23):
|
||||
|
||||
```typescript
|
||||
quantity?: number
|
||||
@@ -290,7 +290,7 @@ quantity?: number
|
||||
|
||||
- [ ] **Step 2: Add `quantity` to `validatePiece()` in same file**
|
||||
|
||||
In `Inventory_frontend/app/shared/types/inventory.ts`, in `validatePiece()` (line ~144-172):
|
||||
In `frontend/app/shared/types/inventory.ts`, in `validatePiece()` (line ~144-172):
|
||||
|
||||
After `const role = ensureString(value.role)` (line ~161), add:
|
||||
|
||||
@@ -306,7 +306,7 @@ And in the return object, add after the `role` spread:
|
||||
|
||||
- [ ] **Step 3: Update `sanitizePieces()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructureSanitize.ts`, in `sanitizePieces()` (~line 130-188).
|
||||
In `frontend/app/shared/model/componentStructureSanitize.ts`, in `sanitizePieces()` (~line 130-188).
|
||||
|
||||
After the existing field extractions, add:
|
||||
|
||||
@@ -324,7 +324,7 @@ if (quantity !== undefined) {
|
||||
|
||||
- [ ] **Step 4: Update `normalizeStructureForSave()` to include quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructure.ts`, in `normalizeStructureForSave()` (~lines 164-179), add in the piece payload mapping after the `reference` check:
|
||||
In `frontend/app/shared/model/componentStructure.ts`, in `normalizeStructureForSave()` (~lines 164-179), add in the piece payload mapping after the `reference` check:
|
||||
|
||||
```typescript
|
||||
if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
|
||||
@@ -336,7 +336,7 @@ if ((piece as any).quantity !== undefined && (piece as any).quantity >= 1) {
|
||||
|
||||
- [ ] **Step 5: Update `hydratePieces()` and `mapComponentPieces()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/model/componentStructureHydrate.ts`:
|
||||
In `frontend/app/shared/model/componentStructureHydrate.ts`:
|
||||
|
||||
In `hydratePieces()` (line ~95-107), add to the mapped object:
|
||||
|
||||
@@ -352,7 +352,7 @@ In `mapComponentPieces()` (line ~168-179), add to the mapped object:
|
||||
|
||||
- [ ] **Step 6: Update `sanitizePieceDefinition()` to preserve quantity**
|
||||
|
||||
In `Inventory_frontend/app/shared/utils/structureAssignmentHelpers.ts`, in `sanitizePieceDefinition()` (~lines 172-180), add to the `stripNullish()` object:
|
||||
In `frontend/app/shared/utils/structureAssignmentHelpers.ts`, in `sanitizePieceDefinition()` (~lines 172-180), add to the `stripNullish()` object:
|
||||
|
||||
```typescript
|
||||
quantity: typeof (definition as any).quantity === 'number' ? (definition as any).quantity : null,
|
||||
@@ -361,14 +361,14 @@ quantity: typeof (definition as any).quantity === 'number' ? (definition as any)
|
||||
- [ ] **Step 7: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/shared/types/inventory.ts app/shared/model/componentStructure.ts app/shared/model/componentStructureSanitize.ts app/shared/model/componentStructureHydrate.ts app/shared/utils/structureAssignmentHelpers.ts
|
||||
git commit -m "feat(piece) : add quantity field to piece types, sanitization and hydration"
|
||||
```
|
||||
@@ -378,14 +378,14 @@ git commit -m "feat(piece) : add quantity field to piece types, sanitization and
|
||||
### Task 5: Composant Structure Editor — Quantity Input
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299)
|
||||
- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` (`addPiece()`, lines ~110-118)
|
||||
- Modify: `frontend/app/components/StructureNodeEditor.vue` (piece section, lines ~229-299)
|
||||
- Modify: `frontend/app/composables/useStructureNodeCrud.ts` (`addPiece()`, lines ~110-118)
|
||||
|
||||
**Context:** `StructureNodeEditor.vue` renders the composant structure editor. The piece section (lines ~236-293) currently shows only a `select` for `typePieceId` and a delete button. The `addPiece()` function in `useStructureNodeCrud.ts` creates new piece entries with default fields.
|
||||
|
||||
- [ ] **Step 1: Add default quantity to `addPiece()`**
|
||||
|
||||
In `Inventory_frontend/app/composables/useStructureNodeCrud.ts`, in `addPiece()` (line ~110-118), add `quantity: 1` to the pushed object:
|
||||
In `frontend/app/composables/useStructureNodeCrud.ts`, in `addPiece()` (line ~110-118), add `quantity: 1` to the pushed object:
|
||||
|
||||
```typescript
|
||||
const addPiece = () => {
|
||||
@@ -403,7 +403,7 @@ const addPiece = () => {
|
||||
|
||||
- [ ] **Step 2: Add quantity input in `StructureNodeEditor.vue`**
|
||||
|
||||
In `Inventory_frontend/app/components/StructureNodeEditor.vue`, in the piece item rendering section (inside the `v-for` loop for pieces, line ~256-292), add a quantity input next to the existing piece type `select`. Place it after the select and before the delete button:
|
||||
In `frontend/app/components/StructureNodeEditor.vue`, in the piece item rendering section (inside the `v-for` loop for pieces, line ~256-292), add a quantity input next to the existing piece type `select`. Place it after the select and before the delete button:
|
||||
|
||||
```vue
|
||||
<input
|
||||
@@ -420,14 +420,14 @@ In `Inventory_frontend/app/components/StructureNodeEditor.vue`, in the piece ite
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/StructureNodeEditor.vue app/composables/useStructureNodeCrud.ts
|
||||
git commit -m "feat(piece) : add quantity input to composant structure editor"
|
||||
```
|
||||
@@ -437,13 +437,13 @@ git commit -m "feat(piece) : add quantity input to composant structure editor"
|
||||
### Task 6: Machine Detail Page — Display Quantity
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
|
||||
- Modify: `frontend/app/components/PieceItem.vue`
|
||||
|
||||
**Context:** `PieceItem.vue` renders each piece in the machine structure view. The piece name is displayed at line ~26 in an `<h3>` tag. Quantity should appear as "×N" after the name, in secondary text. For direct pieces (no parent component), it should be editable. For composant pieces, read-only.
|
||||
|
||||
- [ ] **Step 1: Add quantity display to PieceItem**
|
||||
|
||||
In `Inventory_frontend/app/components/PieceItem.vue`, after the piece name in the `<h3>` tag (line ~26), add the quantity display:
|
||||
In `frontend/app/components/PieceItem.vue`, after the piece name in the `<h3>` tag (line ~26), add the quantity display:
|
||||
|
||||
```vue
|
||||
<span
|
||||
@@ -492,14 +492,14 @@ Ensure this value is included in the data emitted when saving (follow the same p
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/PieceItem.vue
|
||||
git commit -m "feat(piece) : display and edit quantity on machine piece items"
|
||||
```
|
||||
@@ -514,14 +514,14 @@ git commit -m "feat(piece) : display and edit quantity on machine piece items"
|
||||
- [ ] **Step 1: Push frontend commits**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git push
|
||||
cd frontend && git push
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update submodule pointer in main repo**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Inventory
|
||||
git add Inventory_frontend
|
||||
git add frontend
|
||||
git commit -m "chore(frontend) : update submodule — piece quantity feature"
|
||||
```
|
||||
|
||||
|
||||
@@ -1346,13 +1346,13 @@ git commit -m "feat(sync) : add ModelTypeSyncController with preview and sync en
|
||||
### Task 12: Delete `useCategoryEditGuard` composable and tests
|
||||
|
||||
**Files:**
|
||||
- Delete: `Inventory_frontend/app/composables/useCategoryEditGuard.ts`
|
||||
- Delete: `Inventory_frontend/tests/composables/useCategoryEditGuard.test.ts`
|
||||
- Delete: `frontend/app/composables/useCategoryEditGuard.ts`
|
||||
- Delete: `frontend/tests/composables/useCategoryEditGuard.test.ts`
|
||||
|
||||
- [ ] **Step 1: Delete files + commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
rm app/composables/useCategoryEditGuard.ts tests/composables/useCategoryEditGuard.test.ts
|
||||
git add -A && git commit -m "refactor(sync) : remove useCategoryEditGuard composable and tests"
|
||||
```
|
||||
@@ -1362,25 +1362,25 @@ git add -A && git commit -m "refactor(sync) : remove useCategoryEditGuard compos
|
||||
### Task 13: Remove restrictedMode from structure editors and composables
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/StructureNodeEditor.vue` — remove `restrictedMode` prop, `v-if="!restrictedMode"` guards
|
||||
- Modify: `Inventory_frontend/app/components/PieceModelStructureEditor.vue` — same
|
||||
- Modify: `Inventory_frontend/app/components/ComponentModelStructureEditor.vue` — remove prop forwarding
|
||||
- Modify: `Inventory_frontend/app/composables/useStructureNodeCrud.ts` — remove `restrictedMode` from props, remove `isExisting*` guards, remove `initial*Indices`
|
||||
- Modify: `Inventory_frontend/app/composables/useStructureNodeLogic.ts` — remove from props, computed, and crud call
|
||||
- Modify: `Inventory_frontend/app/composables/usePieceStructureEditorLogic.ts` — remove from props, remove `isExisting*` guards
|
||||
- Modify: `frontend/app/components/StructureNodeEditor.vue` — remove `restrictedMode` prop, `v-if="!restrictedMode"` guards
|
||||
- Modify: `frontend/app/components/PieceModelStructureEditor.vue` — same
|
||||
- Modify: `frontend/app/components/ComponentModelStructureEditor.vue` — remove prop forwarding
|
||||
- Modify: `frontend/app/composables/useStructureNodeCrud.ts` — remove `restrictedMode` from props, remove `isExisting*` guards, remove `initial*Indices`
|
||||
- Modify: `frontend/app/composables/useStructureNodeLogic.ts` — remove from props, computed, and crud call
|
||||
- Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts` — remove from props, remove `isExisting*` guards
|
||||
|
||||
- [ ] **Step 1: Remove from each file** (read each file first, then edit)
|
||||
|
||||
- [ ] **Step 2: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from structure editors and composables"
|
||||
cd frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from structure editors and composables"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1388,24 +1388,24 @@ cd Inventory_frontend && git add -A && git commit -m "refactor(sync) : remove re
|
||||
### Task 14: Remove restrictedMode from ModelTypeForm and edit pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/model-types/ModelTypeForm.vue` — remove `restrictedMode`, `disableSubmit`, `disableSubmitMessage`, `restrictedModeMessage` props and warning banner
|
||||
- Modify: `Inventory_frontend/app/pages/component-category/[id]/edit.vue` — remove `useCategoryEditGuard` import/usage, guard props from `<ModelTypeForm>`
|
||||
- Modify: `Inventory_frontend/app/pages/piece-category/[id]/edit.vue` — same
|
||||
- Modify: `Inventory_frontend/app/pages/product-category/[id]/edit.vue` — same
|
||||
- Modify: `Inventory_frontend/tests/components/PieceModelStructureEditor.test.ts` — remove `restrictedMode: true` test cases
|
||||
- Modify: `frontend/app/components/model-types/ModelTypeForm.vue` — remove `restrictedMode`, `disableSubmit`, `disableSubmitMessage`, `restrictedModeMessage` props and warning banner
|
||||
- Modify: `frontend/app/pages/component-category/[id]/edit.vue` — remove `useCategoryEditGuard` import/usage, guard props from `<ModelTypeForm>`
|
||||
- Modify: `frontend/app/pages/piece-category/[id]/edit.vue` — same
|
||||
- Modify: `frontend/app/pages/product-category/[id]/edit.vue` — same
|
||||
- Modify: `frontend/tests/components/PieceModelStructureEditor.test.ts` — remove `restrictedMode: true` test cases
|
||||
|
||||
- [ ] **Step 1: Clean each file** (read first, then edit)
|
||||
|
||||
- [ ] **Step 2: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from ModelTypeForm and category edit pages"
|
||||
cd frontend && git add -A && git commit -m "refactor(sync) : remove restrictedMode from ModelTypeForm and category edit pages"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1415,11 +1415,11 @@ cd Inventory_frontend && git add -A && git commit -m "refactor(sync) : remove re
|
||||
### Task 15: Add sync service functions
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/services/modelTypes.ts`
|
||||
- Modify: `frontend/app/services/modelTypes.ts`
|
||||
|
||||
- [ ] **Step 1: Add `syncPreview` and `syncExecute`**
|
||||
|
||||
Add to the end of `Inventory_frontend/app/services/modelTypes.ts`:
|
||||
Add to the end of `frontend/app/services/modelTypes.ts`:
|
||||
|
||||
```typescript
|
||||
export function syncPreview(id: string, structure: unknown, opts: { signal?: AbortSignal } = {}) {
|
||||
@@ -1466,7 +1466,7 @@ export function syncExecute(id: string, confirmation: { confirmDeletions: boolea
|
||||
- [ ] **Step 2: Run lint + typecheck + commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
git add -A && git commit -m "feat(sync) : add syncPreview and syncExecute service functions"
|
||||
```
|
||||
|
||||
@@ -1475,7 +1475,7 @@ git add -A && git commit -m "feat(sync) : add syncPreview and syncExecute servic
|
||||
### Task 16: Create SyncConfirmationModal component
|
||||
|
||||
**Files:**
|
||||
- Create: `Inventory_frontend/app/components/SyncConfirmationModal.vue`
|
||||
- Create: `frontend/app/components/SyncConfirmationModal.vue`
|
||||
|
||||
- [ ] **Step 1: Create the modal**
|
||||
|
||||
@@ -1487,7 +1487,7 @@ Emits: `confirm`, `cancel`
|
||||
- [ ] **Step 2: Run lint + typecheck + commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
git add -A && git commit -m "feat(sync) : add SyncConfirmationModal component"
|
||||
```
|
||||
|
||||
@@ -1496,9 +1496,9 @@ git add -A && git commit -m "feat(sync) : add SyncConfirmationModal component"
|
||||
### Task 17: Wire sync flow into category edit pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/pages/component-category/[id]/edit.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/piece-category/[id]/edit.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/product-category/[id]/edit.vue`
|
||||
- Modify: `frontend/app/pages/component-category/[id]/edit.vue`
|
||||
- Modify: `frontend/app/pages/piece-category/[id]/edit.vue`
|
||||
- Modify: `frontend/app/pages/product-category/[id]/edit.vue`
|
||||
|
||||
- [ ] **Step 1: Update `component-category/[id]/edit.vue`**
|
||||
|
||||
@@ -1524,13 +1524,13 @@ Same flow, adapt imports and routes.
|
||||
- [ ] **Step 4: Run lint + typecheck + build**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git add -A && git commit -m "feat(sync) : wire sync flow into category edit pages with confirmation modal"
|
||||
cd frontend && git add -A && git commit -m "feat(sync) : wire sync flow into category edit pages with confirmation modal"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -1547,7 +1547,7 @@ Expected: All tests pass.
|
||||
- [ ] **Step 2: Run frontend checks**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run php-cs-fixer**
|
||||
@@ -1561,7 +1561,7 @@ Run: `make php-cs-fixer-allow-risky`
|
||||
- [ ] **Step 1: Update frontend submodule**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend
|
||||
git add frontend
|
||||
git commit -m "chore(submodule) : update frontend pointer for sync feature"
|
||||
```
|
||||
|
||||
|
||||
@@ -574,7 +574,7 @@ git commit -m "test(comments) : add tests for comment creation with file attachm
|
||||
### Task 6: Frontend — update useComments composable
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/useComments.ts`
|
||||
- Modify: `frontend/app/composables/useComments.ts`
|
||||
|
||||
- [ ] **Step 1: Add document type to Comment interface**
|
||||
|
||||
@@ -661,12 +661,12 @@ const createComment = async (
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
|
||||
Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
|
||||
|
||||
- [ ] **Step 4: Commit (in frontend submodule)**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/composables/useComments.ts
|
||||
git commit -m "feat(comments) : support file attachments in createComment"
|
||||
```
|
||||
@@ -676,7 +676,7 @@ git commit -m "feat(comments) : support file attachments in createComment"
|
||||
### Task 7: Frontend — update CommentSection.vue
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/CommentSection.vue`
|
||||
- Modify: `frontend/app/components/CommentSection.vue`
|
||||
|
||||
- [ ] **Step 1: Add file input and file list display to the template**
|
||||
|
||||
@@ -810,12 +810,12 @@ const handleSubmit = async () => {
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
|
||||
Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
|
||||
|
||||
- [ ] **Step 4: Commit (in frontend submodule)**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/CommentSection.vue
|
||||
git commit -m "feat(comments) : add file attachment UI to CommentSection"
|
||||
```
|
||||
@@ -848,7 +848,7 @@ git commit -m "feat(documents) : add comment ExistsFilter"
|
||||
- [ ] **Step 4: Update submodule pointer**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend
|
||||
git add frontend
|
||||
git commit -m "chore(submodule) : update frontend pointer (comment documents feature)"
|
||||
```
|
||||
|
||||
|
||||
@@ -22,15 +22,15 @@
|
||||
- `src/Controller/DocumentQueryController.php` — add `type` to `normalizeDocuments()`
|
||||
|
||||
### Frontend (create)
|
||||
- `Inventory_frontend/app/shared/documentTypes.ts` — type constants + labels
|
||||
- `Inventory_frontend/app/components/DocumentEditModal.vue` — mini-modal for editing name+type
|
||||
- `frontend/app/shared/documentTypes.ts` — type constants + labels
|
||||
- `frontend/app/components/DocumentEditModal.vue` — mini-modal for editing name+type
|
||||
|
||||
### Frontend (modify)
|
||||
- `Inventory_frontend/app/composables/useDocuments.ts` — add `type` to interface + `updateDocument()` method
|
||||
- `Inventory_frontend/app/components/DocumentUpload.vue` — add type select
|
||||
- `Inventory_frontend/app/components/common/DocumentListInline.vue` — add type badge + edit button
|
||||
- `Inventory_frontend/app/composables/useEntityDocuments.ts` — add `updateDocument` delegation
|
||||
- `Inventory_frontend/app/pages/documents.vue` — add type column + edit button
|
||||
- `frontend/app/composables/useDocuments.ts` — add `type` to interface + `updateDocument()` method
|
||||
- `frontend/app/components/DocumentUpload.vue` — add type select
|
||||
- `frontend/app/components/common/DocumentListInline.vue` — add type badge + edit button
|
||||
- `frontend/app/composables/useEntityDocuments.ts` — add `updateDocument` delegation
|
||||
- `frontend/app/pages/documents.vue` — add type column + edit button
|
||||
|
||||
---
|
||||
|
||||
@@ -266,13 +266,13 @@ git commit -m "feat(documents) : accept type on upload + expose in query control
|
||||
### Task 4: Frontend — Type Constants + Document Interface
|
||||
|
||||
**Files:**
|
||||
- Create: `Inventory_frontend/app/shared/documentTypes.ts`
|
||||
- Modify: `Inventory_frontend/app/composables/useDocuments.ts:6-27` (Document interface), `useDocuments.ts:205-253` (upload)
|
||||
- Create: `frontend/app/shared/documentTypes.ts`
|
||||
- Modify: `frontend/app/composables/useDocuments.ts:6-27` (Document interface), `useDocuments.ts:205-253` (upload)
|
||||
|
||||
- [ ] **Step 1: Create documentTypes.ts**
|
||||
|
||||
```typescript
|
||||
// Inventory_frontend/app/shared/documentTypes.ts
|
||||
// frontend/app/shared/documentTypes.ts
|
||||
export const DOCUMENT_TYPES = [
|
||||
{ value: 'documentation', label: 'Documentation' },
|
||||
{ value: 'devis', label: 'Devis' },
|
||||
@@ -355,12 +355,12 @@ Add `updateDocument` to the return object.
|
||||
|
||||
- [ ] **Step 5: Run lint**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix`
|
||||
Run: `cd frontend && npm run lint:fix`
|
||||
|
||||
- [ ] **Step 6: Commit frontend**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/shared/documentTypes.ts app/composables/useDocuments.ts
|
||||
git commit -m "feat(documents) : add document type constants and updateDocument method"
|
||||
```
|
||||
@@ -370,7 +370,7 @@ git commit -m "feat(documents) : add document type constants and updateDocument
|
||||
### Task 5: Frontend — DocumentUpload Type Select
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/DocumentUpload.vue`
|
||||
- Modify: `frontend/app/components/DocumentUpload.vue`
|
||||
|
||||
- [ ] **Step 1: Add type prop and select to DocumentUpload**
|
||||
|
||||
@@ -419,12 +419,12 @@ Note: since DocumentUpload uses `<script setup>` without `lang="ts"`, use `@chan
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix`
|
||||
Run: `cd frontend && npm run lint:fix`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/DocumentUpload.vue
|
||||
git commit -m "feat(documents) : add type select to DocumentUpload component"
|
||||
```
|
||||
@@ -434,7 +434,7 @@ git commit -m "feat(documents) : add type select to DocumentUpload component"
|
||||
### Task 6: Frontend — DocumentEditModal
|
||||
|
||||
**Files:**
|
||||
- Create: `Inventory_frontend/app/components/DocumentEditModal.vue`
|
||||
- Create: `frontend/app/components/DocumentEditModal.vue`
|
||||
|
||||
- [ ] **Step 1: Create DocumentEditModal component**
|
||||
|
||||
@@ -533,12 +533,12 @@ const save = () => {
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix`
|
||||
Run: `cd frontend && npm run lint:fix`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/DocumentEditModal.vue
|
||||
git commit -m "feat(documents) : add DocumentEditModal component"
|
||||
```
|
||||
@@ -548,8 +548,8 @@ git commit -m "feat(documents) : add DocumentEditModal component"
|
||||
### Task 7: Frontend — DocumentListInline + Type Badge + Edit Button
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/common/DocumentListInline.vue`
|
||||
- Modify: `Inventory_frontend/app/composables/useEntityDocuments.ts`
|
||||
- Modify: `frontend/app/components/common/DocumentListInline.vue`
|
||||
- Modify: `frontend/app/composables/useEntityDocuments.ts`
|
||||
|
||||
- [ ] **Step 1: Add type badge and edit button to DocumentListInline**
|
||||
|
||||
@@ -622,12 +622,12 @@ Add `editDocument` to the return object.
|
||||
|
||||
- [ ] **Step 3: Run lint**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix`
|
||||
Run: `cd frontend && npm run lint:fix`
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/common/DocumentListInline.vue app/composables/useEntityDocuments.ts
|
||||
git commit -m "feat(documents) : add type badge and edit button to DocumentListInline"
|
||||
```
|
||||
@@ -637,11 +637,11 @@ git commit -m "feat(documents) : add type badge and edit button to DocumentListI
|
||||
### Task 8: Frontend — Wire Edit Modal in Entity Pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/ComponentItem.vue`
|
||||
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/pieces/[id]/edit.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
|
||||
- Modify: `frontend/app/components/ComponentItem.vue`
|
||||
- Modify: `frontend/app/components/PieceItem.vue`
|
||||
- Modify: `frontend/app/pages/pieces/[id]/edit.vue`
|
||||
- Modify: `frontend/app/pages/component/[id]/edit.vue`
|
||||
- Modify: `frontend/app/pages/product/[id]/edit.vue`
|
||||
|
||||
- [ ] **Step 1: Wire in ComponentItem and PieceItem**
|
||||
|
||||
@@ -688,12 +688,12 @@ Pass `type: uploadDocType.value` in the upload context when calling `handleFiles
|
||||
|
||||
- [ ] **Step 4: Run lint + typecheck**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
|
||||
Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/ app/pages/
|
||||
git commit -m "feat(documents) : wire DocumentEditModal and type select in all entity pages"
|
||||
```
|
||||
@@ -703,7 +703,7 @@ git commit -m "feat(documents) : wire DocumentEditModal and type select in all e
|
||||
### Task 9: Frontend — Documents Global Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/pages/documents.vue`
|
||||
- Modify: `frontend/app/pages/documents.vue`
|
||||
|
||||
- [ ] **Step 1: Add type column to DataTable**
|
||||
|
||||
@@ -765,12 +765,12 @@ Pass `typeFilter` to `fetchDocuments` → `loadDocuments` as a new filter param,
|
||||
|
||||
- [ ] **Step 4: Run lint + typecheck**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck`
|
||||
Run: `cd frontend && npm run lint:fix && npx nuxi typecheck`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/pages/documents.vue app/composables/useDocuments.ts
|
||||
git commit -m "feat(documents) : add type column, filter, and edit to documents page"
|
||||
```
|
||||
@@ -789,7 +789,7 @@ Expected: all tests pass
|
||||
|
||||
- [ ] **Step 2: Run full frontend checks**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck && npm run build`
|
||||
Run: `cd frontend && npm run lint:fix && npx nuxi typecheck && npm run build`
|
||||
Expected: 0 errors
|
||||
|
||||
- [ ] **Step 3: Manual verification**
|
||||
@@ -804,6 +804,6 @@ Expected: 0 errors
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Inventory
|
||||
git add Inventory_frontend
|
||||
git add frontend
|
||||
git commit -m "chore(submodule) : update frontend pointer (document types feature)"
|
||||
```
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:195-236` |
|
||||
| T3 | Modify | `src/Service/ModelTypeCategoryConversionService.php:340-405` |
|
||||
| T4 | Modify | `src/Controller/CustomFieldValueController.php:199-211` |
|
||||
| T5 | Modify | `Inventory_frontend/app/composables/useComponentEdit.ts:398-405` |
|
||||
| T5 | Modify | `Inventory_frontend/app/composables/usePieceEdit.ts:407-414` |
|
||||
| T6 | Modify | `Inventory_frontend/app/composables/useComponentCreate.ts` (same pattern if present) |
|
||||
| T5 | Modify | `frontend/app/composables/useComponentEdit.ts:398-405` |
|
||||
| T5 | Modify | `frontend/app/composables/usePieceEdit.ts:407-414` |
|
||||
| T6 | Modify | `frontend/app/composables/useComponentCreate.ts` (same pattern if present) |
|
||||
|
||||
---
|
||||
|
||||
@@ -347,8 +347,8 @@ git commit -m "fix(custom-fields) : prevent creation of orphan CustomField witho
|
||||
Consequence : le `definitionMap` est toujours vide, les champs perso sans `customFieldId` existant ne trouvent pas leur definition et sont envoyes sans `definitionId` (fallback sur metadata = CustomField orphelin cote backend = Task 4).
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts:401-403`
|
||||
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts:410-412`
|
||||
- Modify: `frontend/app/composables/useComponentEdit.ts:401-403`
|
||||
- Modify: `frontend/app/composables/usePieceEdit.ts:410-412`
|
||||
|
||||
- [ ] **Step 1: Fix useComponentEdit.ts**
|
||||
|
||||
@@ -387,13 +387,13 @@ Verifier `useComponentCreate.ts`, `pieces/create.vue`, `product/[id]/edit.vue` p
|
||||
- [ ] **Step 4: Lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/composables/useComponentEdit.ts app/composables/usePieceEdit.ts
|
||||
git commit -m "fix(custom-fields) : use structure.customFields path for definition lookup"
|
||||
```
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
### Task 1: Multi-select site checkboxes on Parc Machines
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/pages/machines/index.vue`
|
||||
- Modify: `frontend/app/pages/machines/index.vue`
|
||||
|
||||
- [ ] **Step 1: Replace `selectedSite` ref with reactive Set**
|
||||
|
||||
@@ -90,14 +90,14 @@ Open `http://localhost:3001/machines`. Confirm:
|
||||
|
||||
- [ ] **Step 7: Run frontend lint**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix`
|
||||
Run: `cd frontend && npm run lint:fix`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Alphabetical sorting on Parc Machines
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/pages/machines/index.vue`
|
||||
- Modify: `frontend/app/pages/machines/index.vue`
|
||||
|
||||
- [ ] **Step 1: Add sort to `filteredMachines` computed**
|
||||
|
||||
@@ -140,7 +140,7 @@ Open `http://localhost:3001/machines`. Confirm machines are sorted A→Z by name
|
||||
- [ ] **Step 3: Commit Tasks 1 + 2**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort"
|
||||
cd frontend && git add app/pages/machines/index.vue && git commit -m "feat(machines) : multi-select site checkboxes + alphabetical sort"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -342,9 +342,9 @@ git add src/Doctrine/SearchByNameOrReferenceExtension.php tests/Api/FilterTest.p
|
||||
### Task 4: Frontend — Switch composables from `name` to `q`
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/usePieces.ts`
|
||||
- Modify: `Inventory_frontend/app/composables/useComposants.ts`
|
||||
- Modify: `Inventory_frontend/app/composables/useProducts.ts`
|
||||
- Modify: `frontend/app/composables/usePieces.ts`
|
||||
- Modify: `frontend/app/composables/useComposants.ts`
|
||||
- Modify: `frontend/app/composables/useProducts.ts`
|
||||
|
||||
- [ ] **Step 1: Update `usePieces.ts`**
|
||||
|
||||
@@ -385,7 +385,7 @@ params.set('q', search.trim())
|
||||
|
||||
- [ ] **Step 4: Run frontend lint**
|
||||
|
||||
Run: `cd Inventory_frontend && npm run lint:fix`
|
||||
Run: `cd frontend && npm run lint:fix`
|
||||
|
||||
- [ ] **Step 5: Verify in browser**
|
||||
|
||||
@@ -399,11 +399,11 @@ Confirm that searching by a reference value returns the correct results.
|
||||
- [ ] **Step 6: Commit frontend changes**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git add app/composables/usePieces.ts app/composables/useComposants.ts app/composables/useProducts.ts && git commit -m "feat(search) : use q param for OR search on name/reference"
|
||||
cd frontend && git add app/composables/usePieces.ts app/composables/useComposants.ts app/composables/useProducts.ts && git commit -m "feat(search) : use q param for OR search on name/reference"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Update submodule pointer in main repo**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Inventory && git add Inventory_frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)"
|
||||
cd /home/matthieu/dev_malio/Inventory && git add frontend && git commit -m "chore(submodule) : update frontend pointer (OR search + site checkboxes)"
|
||||
```
|
||||
|
||||
@@ -1100,13 +1100,13 @@ git commit -m "feat(detail) : update catalogs and cross-links to use detail page
|
||||
- [ ] **Step 1: Run lint**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix
|
||||
cd frontend && npm run lint:fix
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npx nuxi typecheck
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Fix any errors found.
|
||||
|
||||
@@ -29,15 +29,15 @@
|
||||
- `src/Repository/AuditLogRepository.php` — Add `findVersionHistory()` method
|
||||
|
||||
### Frontend — New Files
|
||||
- `Inventory_frontend/app/composables/useEntityVersions.ts` — API calls for versions/preview/restore
|
||||
- `Inventory_frontend/app/components/common/EntityVersionList.vue` — Version list with restore button
|
||||
- `Inventory_frontend/app/components/common/VersionRestoreModal.vue` — Preview + confirm modal
|
||||
- `frontend/app/composables/useEntityVersions.ts` — API calls for versions/preview/restore
|
||||
- `frontend/app/components/common/EntityVersionList.vue` — Version list with restore button
|
||||
- `frontend/app/components/common/VersionRestoreModal.vue` — Preview + confirm modal
|
||||
|
||||
### Frontend — Modified Files
|
||||
- `Inventory_frontend/app/pages/machine/[id].vue` — Add Versions section
|
||||
- `Inventory_frontend/app/pages/component/[id]/edit.vue` — Add Versions section
|
||||
- `Inventory_frontend/app/pages/piece/[id].vue` — Add Versions section
|
||||
- `Inventory_frontend/app/pages/product/[id]/edit.vue` — Add Versions section
|
||||
- `frontend/app/pages/machine/[id].vue` — Add Versions section
|
||||
- `frontend/app/pages/component/[id]/edit.vue` — Add Versions section
|
||||
- `frontend/app/pages/piece/[id].vue` — Add Versions section
|
||||
- `frontend/app/pages/product/[id]/edit.vue` — Add Versions section
|
||||
|
||||
---
|
||||
|
||||
@@ -1870,7 +1870,7 @@ git commit -m "test(versioning) : add EntityVersionTest for list, preview and re
|
||||
## Task 9b: Frontend — add `restore` action label to historyDisplayUtils
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/shared/utils/historyDisplayUtils.ts`
|
||||
- Modify: `frontend/app/shared/utils/historyDisplayUtils.ts`
|
||||
|
||||
- [ ] **Step 1: Add `restore` case to `historyActionLabel`**
|
||||
|
||||
@@ -1888,7 +1888,7 @@ export const historyActionLabel = (action: string): string => {
|
||||
- [ ] **Step 2: Commit in frontend repo**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/shared/utils/historyDisplayUtils.ts
|
||||
git commit -m "feat(versioning) : add restore action label to historyDisplayUtils"
|
||||
cd ..
|
||||
@@ -1899,7 +1899,7 @@ cd ..
|
||||
## Task 10: Frontend — useEntityVersions composable
|
||||
|
||||
**Files:**
|
||||
- Create: `Inventory_frontend/app/composables/useEntityVersions.ts`
|
||||
- Create: `frontend/app/composables/useEntityVersions.ts`
|
||||
|
||||
- [ ] **Step 1: Create the composable**
|
||||
|
||||
@@ -2004,16 +2004,16 @@ export function useEntityVersions(deps: Deps) {
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run (in `Inventory_frontend/`): `npm run lint:fix`
|
||||
Run (in `frontend/`): `npm run lint:fix`
|
||||
|
||||
- [ ] **Step 3: Run typecheck**
|
||||
|
||||
Run (in `Inventory_frontend/`): `npx nuxi typecheck`
|
||||
Run (in `frontend/`): `npx nuxi typecheck`
|
||||
|
||||
- [ ] **Step 4: Commit in frontend repo**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/composables/useEntityVersions.ts
|
||||
git commit -m "feat(versioning) : add useEntityVersions composable"
|
||||
cd ..
|
||||
@@ -2024,7 +2024,7 @@ cd ..
|
||||
## Task 11: Frontend — VersionRestoreModal component
|
||||
|
||||
**Files:**
|
||||
- Create: `Inventory_frontend/app/components/common/VersionRestoreModal.vue`
|
||||
- Create: `frontend/app/components/common/VersionRestoreModal.vue`
|
||||
|
||||
- [ ] **Step 1: Create the modal component**
|
||||
|
||||
@@ -2129,12 +2129,12 @@ const formatValue = (value: unknown): string => {
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run (in `Inventory_frontend/`): `npm run lint:fix`
|
||||
Run (in `frontend/`): `npm run lint:fix`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/common/VersionRestoreModal.vue
|
||||
git commit -m "feat(versioning) : add VersionRestoreModal component"
|
||||
cd ..
|
||||
@@ -2145,7 +2145,7 @@ cd ..
|
||||
## Task 12: Frontend — EntityVersionList component
|
||||
|
||||
**Files:**
|
||||
- Create: `Inventory_frontend/app/components/common/EntityVersionList.vue`
|
||||
- Create: `frontend/app/components/common/EntityVersionList.vue`
|
||||
|
||||
- [ ] **Step 1: Create the version list component**
|
||||
|
||||
@@ -2296,12 +2296,12 @@ onMounted(() => {
|
||||
|
||||
- [ ] **Step 2: Run lint**
|
||||
|
||||
Run (in `Inventory_frontend/`): `npm run lint:fix`
|
||||
Run (in `frontend/`): `npm run lint:fix`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/components/common/EntityVersionList.vue
|
||||
git commit -m "feat(versioning) : add EntityVersionList component with restore flow"
|
||||
cd ..
|
||||
@@ -2312,10 +2312,10 @@ cd ..
|
||||
## Task 13: Frontend — Integrate EntityVersionList into detail pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/pages/machine/[id].vue`
|
||||
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/piece/[id].vue`
|
||||
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
|
||||
- Modify: `frontend/app/pages/machine/[id].vue`
|
||||
- Modify: `frontend/app/pages/component/[id]/edit.vue`
|
||||
- Modify: `frontend/app/pages/piece/[id].vue`
|
||||
- Modify: `frontend/app/pages/product/[id]/edit.vue`
|
||||
|
||||
- [ ] **Step 1: Add EntityVersionList to machine/[id].vue**
|
||||
|
||||
@@ -2398,16 +2398,16 @@ import EntityVersionList from '~/components/common/EntityVersionList.vue'
|
||||
|
||||
- [ ] **Step 5: Run lint**
|
||||
|
||||
Run (in `Inventory_frontend/`): `npm run lint:fix`
|
||||
Run (in `frontend/`): `npm run lint:fix`
|
||||
|
||||
- [ ] **Step 6: Run typecheck**
|
||||
|
||||
Run (in `Inventory_frontend/`): `npx nuxi typecheck`
|
||||
Run (in `frontend/`): `npx nuxi typecheck`
|
||||
|
||||
- [ ] **Step 7: Commit in frontend repo**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend
|
||||
cd frontend
|
||||
git add app/pages/machine/\[id\].vue app/pages/component/\[id\]/edit.vue app/pages/piece/\[id\].vue app/pages/product/\[id\]/edit.vue
|
||||
git commit -m "feat(versioning) : integrate EntityVersionList into all detail pages"
|
||||
cd ..
|
||||
@@ -2416,7 +2416,7 @@ cd ..
|
||||
- [ ] **Step 8: Update submodule pointer in main repo**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend
|
||||
git add frontend
|
||||
git commit -m "chore(submodule) : update frontend pointer (entity versioning)"
|
||||
```
|
||||
|
||||
@@ -2431,7 +2431,7 @@ Expected: All tests pass.
|
||||
|
||||
- [ ] **Step 2: Run frontend lint + typecheck**
|
||||
|
||||
Run (in `Inventory_frontend/`): `npm run lint:fix && npx nuxi typecheck`
|
||||
Run (in `frontend/`): `npm run lint:fix && npx nuxi typecheck`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 3: Manual smoke test**
|
||||
|
||||
@@ -14,12 +14,12 @@
|
||||
|
||||
| Action | File | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Modify | `Inventory_frontend/app/components/machine/MachineInfoCard.vue` | Remove blur-triggered saves, expose saveFieldDefinitions via defineExpose |
|
||||
| Modify | `Inventory_frontend/app/components/machine/MachineCustomFieldDefEditor.vue` | Remove standalone save button |
|
||||
| Modify | `Inventory_frontend/app/pages/machine/[id].vue` | Add Save/Cancel buttons, wire submitEdition via template ref |
|
||||
| Modify | `Inventory_frontend/app/composables/useMachineDetailData.ts` | Add submitEdition, cancelEdition, saving, canSubmit |
|
||||
| Modify | `Inventory_frontend/app/composables/useMachineDetailUpdates.ts` | Remove auto-save from handleMachineConstructeurChange |
|
||||
| Modify | `Inventory_frontend/app/composables/useMachineDetailCustomFields.ts` | Add saveAllMachineCustomFields batch method |
|
||||
| Modify | `frontend/app/components/machine/MachineInfoCard.vue` | Remove blur-triggered saves, expose saveFieldDefinitions via defineExpose |
|
||||
| Modify | `frontend/app/components/machine/MachineCustomFieldDefEditor.vue` | Remove standalone save button |
|
||||
| Modify | `frontend/app/pages/machine/[id].vue` | Add Save/Cancel buttons, wire submitEdition via template ref |
|
||||
| Modify | `frontend/app/composables/useMachineDetailData.ts` | Add submitEdition, cancelEdition, saving, canSubmit |
|
||||
| Modify | `frontend/app/composables/useMachineDetailUpdates.ts` | Remove auto-save from handleMachineConstructeurChange |
|
||||
| Modify | `frontend/app/composables/useMachineDetailCustomFields.ts` | Add saveAllMachineCustomFields batch method |
|
||||
| Modify | `src/EventSubscriber/MachineAuditSubscriber.php` | Enrich snapshot with links + detect link changes in onFlush |
|
||||
|
||||
---
|
||||
@@ -27,7 +27,7 @@
|
||||
### Task 1: Remove blur-triggered saves from MachineInfoCard
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/machine/MachineInfoCard.vue`
|
||||
- Modify: `frontend/app/components/machine/MachineInfoCard.vue`
|
||||
|
||||
- [ ] **Step 1: Remove `@blur` from name input (line 17)**
|
||||
|
||||
@@ -143,7 +143,7 @@ defineExpose({
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend/app/components/machine/MachineInfoCard.vue
|
||||
git add frontend/app/components/machine/MachineInfoCard.vue
|
||||
git commit -m "refactor(machine) : remove blur-triggered auto-saves from MachineInfoCard"
|
||||
```
|
||||
|
||||
@@ -152,7 +152,7 @@ git commit -m "refactor(machine) : remove blur-triggered auto-saves from Machine
|
||||
### Task 2: Remove standalone save button from MachineCustomFieldDefEditor
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
|
||||
- Modify: `frontend/app/components/machine/MachineCustomFieldDefEditor.vue`
|
||||
|
||||
- [ ] **Step 1: Remove the "Enregistrer les champs" button (lines 7-15)**
|
||||
|
||||
@@ -201,7 +201,7 @@ defineEmits<{
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend/app/components/machine/MachineCustomFieldDefEditor.vue
|
||||
git add frontend/app/components/machine/MachineCustomFieldDefEditor.vue
|
||||
git commit -m "refactor(machine) : remove standalone save button from custom field def editor"
|
||||
```
|
||||
|
||||
@@ -210,7 +210,7 @@ git commit -m "refactor(machine) : remove standalone save button from custom fie
|
||||
### Task 3: Stop auto-save in handleMachineConstructeurChange
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/useMachineDetailUpdates.ts:211-214`
|
||||
- Modify: `frontend/app/composables/useMachineDetailUpdates.ts:211-214`
|
||||
|
||||
- [ ] **Step 1: Remove the auto-save call**
|
||||
|
||||
@@ -231,7 +231,7 @@ With:
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend/app/composables/useMachineDetailUpdates.ts
|
||||
git add frontend/app/composables/useMachineDetailUpdates.ts
|
||||
git commit -m "refactor(machine) : stop auto-saving on constructeur change"
|
||||
```
|
||||
|
||||
@@ -240,7 +240,7 @@ git commit -m "refactor(machine) : stop auto-saving on constructeur change"
|
||||
### Task 4: Add batch custom field save method
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/useMachineDetailCustomFields.ts`
|
||||
- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts`
|
||||
|
||||
- [ ] **Step 1: Add `saveAllMachineCustomFields` method**
|
||||
|
||||
@@ -325,7 +325,7 @@ With:
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend/app/composables/useMachineDetailCustomFields.ts
|
||||
git add frontend/app/composables/useMachineDetailCustomFields.ts
|
||||
git commit -m "feat(machine) : add batch saveAllMachineCustomFields method"
|
||||
```
|
||||
|
||||
@@ -334,7 +334,7 @@ git commit -m "feat(machine) : add batch saveAllMachineCustomFields method"
|
||||
### Task 5: Add submitEdition, cancelEdition, saving, canSubmit to orchestrator
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/useMachineDetailData.ts`
|
||||
- Modify: `frontend/app/composables/useMachineDetailData.ts`
|
||||
|
||||
- [ ] **Step 1: Add `saving` ref in the core state block (after line 63)**
|
||||
|
||||
@@ -423,7 +423,7 @@ Add to the return object:
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend/app/composables/useMachineDetailData.ts
|
||||
git add frontend/app/composables/useMachineDetailData.ts
|
||||
git commit -m "feat(machine) : add submitEdition, cancelEdition, saving, canSubmit"
|
||||
```
|
||||
|
||||
@@ -432,7 +432,7 @@ git commit -m "feat(machine) : add submitEdition, cancelEdition, saving, canSubm
|
||||
### Task 6: Wire Save/Cancel buttons in the page
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/pages/machine/[id].vue`
|
||||
- Modify: `frontend/app/pages/machine/[id].vue`
|
||||
|
||||
- [ ] **Step 1: Add template ref on MachineInfoCard (line 56)**
|
||||
|
||||
@@ -597,7 +597,7 @@ const historyFieldLabels = {
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add Inventory_frontend/app/pages/machine/[id].vue
|
||||
git add frontend/app/pages/machine/[id].vue
|
||||
git commit -m "feat(machine) : add single save button and wire cancel/submit"
|
||||
```
|
||||
|
||||
@@ -988,13 +988,13 @@ git commit -m "feat(versioning) : detect machine link add/remove in onFlush and
|
||||
- [ ] **Step 1: Run ESLint fix**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix
|
||||
cd frontend && npm run lint:fix
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npx nuxi typecheck
|
||||
cd frontend && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
@@ -1039,7 +1039,7 @@ git add -A && git commit -m "fix(machine) : fix cs-fixer and test issues from si
|
||||
|
||||
```bash
|
||||
make start
|
||||
cd Inventory_frontend && npm run dev
|
||||
cd frontend && npm run dev
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test single save flow**
|
||||
|
||||
@@ -94,8 +94,8 @@ git commit --no-verify -m "feat(constructeur) : add SearchFilter on Constructeur
|
||||
### Task F2: Frontend — Add types + useConstructeurLinks composable
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/shared/constructeurUtils.ts`
|
||||
- Create: `Inventory_frontend/app/composables/useConstructeurLinks.ts`
|
||||
- Modify: `frontend/app/shared/constructeurUtils.ts`
|
||||
- Create: `frontend/app/composables/useConstructeurLinks.ts`
|
||||
|
||||
- [ ] **Step 1: Add ConstructeurLinkEntry type to constructeurUtils.ts**
|
||||
|
||||
@@ -227,7 +227,7 @@ export function useConstructeurLinks() {
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinkEntry type and useConstructeurLinks composable"
|
||||
cd frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinkEntry type and useConstructeurLinks composable"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -235,7 +235,7 @@ cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add C
|
||||
### Task F3: Frontend — Create ConstructeurLinksTable component
|
||||
|
||||
**Files:**
|
||||
- Create: `Inventory_frontend/app/components/ConstructeurLinksTable.vue`
|
||||
- Create: `frontend/app/components/ConstructeurLinksTable.vue`
|
||||
|
||||
- [ ] **Step 1: Create the component**
|
||||
|
||||
@@ -338,7 +338,7 @@ const removeLink = (index: number) => {
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinksTable component"
|
||||
cd frontend && git add -A && git commit -m "feat(constructeur) : add ConstructeurLinksTable component"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -346,9 +346,9 @@ cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : add C
|
||||
### Task F4: Frontend — Update piece edit flow (model case)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/usePieceEdit.ts`
|
||||
- Modify: `Inventory_frontend/app/pages/piece/[id].vue`
|
||||
- Modify: `Inventory_frontend/app/composables/usePieces.ts`
|
||||
- Modify: `frontend/app/composables/usePieceEdit.ts`
|
||||
- Modify: `frontend/app/pages/piece/[id].vue`
|
||||
- Modify: `frontend/app/composables/usePieces.ts`
|
||||
|
||||
This task establishes the pattern for all entity types.
|
||||
|
||||
@@ -376,13 +376,13 @@ In `createPiece()` and `updatePieceData()`: stop wrapping payload with `buildCon
|
||||
- [ ] **Step 4: Lint and typecheck**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && npm run lint:fix && npx nuxi typecheck
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : update piece edit flow with supplier references"
|
||||
cd frontend && git add -A && git commit -m "feat(constructeur) : update piece edit flow with supplier references"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -392,11 +392,11 @@ cd Inventory_frontend && git add -A && git commit -m "feat(constructeur) : updat
|
||||
Same pattern as Task F4 but for composants.
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/useComponentEdit.ts`
|
||||
- Modify: `Inventory_frontend/app/pages/component/[id]/index.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/component/[id]/edit.vue`
|
||||
- Modify: `Inventory_frontend/app/composables/useComposants.ts`
|
||||
- Modify: `Inventory_frontend/app/pages/component/create.vue`
|
||||
- Modify: `frontend/app/composables/useComponentEdit.ts`
|
||||
- Modify: `frontend/app/pages/component/[id]/index.vue`
|
||||
- Modify: `frontend/app/pages/component/[id]/edit.vue`
|
||||
- Modify: `frontend/app/composables/useComposants.ts`
|
||||
- Modify: `frontend/app/pages/component/create.vue`
|
||||
|
||||
---
|
||||
|
||||
@@ -406,10 +406,10 @@ Same pattern as Task F4 but for products.
|
||||
|
||||
**Files:**
|
||||
- Modify: product edit composable (if exists) or inline pages
|
||||
- Modify: `Inventory_frontend/app/pages/product/[id]/index.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/product/[id]/edit.vue`
|
||||
- Modify: `Inventory_frontend/app/composables/useProducts.ts`
|
||||
- Modify: `Inventory_frontend/app/pages/product/create.vue`
|
||||
- Modify: `frontend/app/pages/product/[id]/index.vue`
|
||||
- Modify: `frontend/app/pages/product/[id]/edit.vue`
|
||||
- Modify: `frontend/app/composables/useProducts.ts`
|
||||
- Modify: `frontend/app/pages/product/create.vue`
|
||||
|
||||
---
|
||||
|
||||
@@ -418,11 +418,11 @@ Same pattern as Task F4 but for products.
|
||||
Machine uses a different architecture (MachineStructureController, useMachineDetailData/Updates).
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/composables/useMachineDetailData.ts`
|
||||
- Modify: `Inventory_frontend/app/composables/useMachineDetailUpdates.ts`
|
||||
- Modify: `Inventory_frontend/app/pages/machine/[id].vue`
|
||||
- Modify: `Inventory_frontend/app/components/machine/MachineInfoCard.vue`
|
||||
- Modify: `Inventory_frontend/app/composables/useMachines.ts`
|
||||
- Modify: `frontend/app/composables/useMachineDetailData.ts`
|
||||
- Modify: `frontend/app/composables/useMachineDetailUpdates.ts`
|
||||
- Modify: `frontend/app/pages/machine/[id].vue`
|
||||
- Modify: `frontend/app/components/machine/MachineInfoCard.vue`
|
||||
- Modify: `frontend/app/composables/useMachines.ts`
|
||||
|
||||
Key differences:
|
||||
- Machine data comes from `/api/machines/{id}/structure` (custom controller) which already returns the new constructeur link format
|
||||
@@ -434,8 +434,8 @@ Key differences:
|
||||
### Task F8: Frontend — Update machine structure components (PieceItem, ComponentItem)
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/components/PieceItem.vue`
|
||||
- Modify: `Inventory_frontend/app/components/ComponentItem.vue`
|
||||
- Modify: `frontend/app/components/PieceItem.vue`
|
||||
- Modify: `frontend/app/components/ComponentItem.vue`
|
||||
|
||||
These components display constructeurs in the machine structure tree and handle inline editing. Update them to:
|
||||
- Read from `constructeurLinks` format in the machine structure response
|
||||
@@ -447,9 +447,9 @@ These components display constructeurs in the machine structure tree and handle
|
||||
### Task F9: Frontend — Update create pages
|
||||
|
||||
**Files:**
|
||||
- Modify: `Inventory_frontend/app/pages/pieces/create.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/component/create.vue`
|
||||
- Modify: `Inventory_frontend/app/pages/product/create.vue`
|
||||
- Modify: `frontend/app/pages/pieces/create.vue`
|
||||
- Modify: `frontend/app/pages/component/create.vue`
|
||||
- Modify: `frontend/app/pages/product/create.vue`
|
||||
|
||||
On creation pages, there are no existing links. The flow is:
|
||||
1. User selects constructeurs + optionally fills supplierReference
|
||||
|
||||
1182
docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md
Normal file
1182
docs/superpowers/plans/2026-04-02-machine-context-custom-fields.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,841 @@
|
||||
# Machine Context Custom Fields — Backend 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.
|
||||
> **Parallel plan:** This is the backend half. The frontend plan is at `2026-04-02-machine-context-fields-frontend.md`. Both can run in parallel on separate worktrees — they share no files.
|
||||
|
||||
**Goal:** Add `machineContextOnly` flag on `CustomField`, link FKs on `CustomFieldValue`, structure controller normalization, upsert support, clone support, and tests.
|
||||
|
||||
**Architecture:** `CustomField.machineContextOnly` flags definitions. `CustomFieldValue` gets nullable FKs to `MachineComponentLink`/`MachinePieceLink`. The structure response returns `contextCustomFields` and `contextCustomFieldValues` per link. Upsert and clone are extended.
|
||||
|
||||
**Tech Stack:** Symfony 8, Doctrine ORM, API Platform 4, PostgreSQL 16, PHPUnit 12
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Create
|
||||
- `migrations/VersionXXXX_MachineContextCustomFields.php` — migration
|
||||
- `tests/Api/Entity/MachineContextCustomFieldTest.php` — test class
|
||||
|
||||
### Modify
|
||||
- `src/Entity/CustomField.php` — add `machineContextOnly` property
|
||||
- `src/Entity/CustomFieldValue.php` — add `machineComponentLink` and `machinePieceLink` FKs
|
||||
- `src/Entity/MachineComponentLink.php` — add `contextFieldValues` collection
|
||||
- `src/Entity/MachinePieceLink.php` — add `contextFieldValues` collection
|
||||
- `src/Controller/MachineStructureController.php` — normalize context fields + clone
|
||||
- `src/Controller/CustomFieldValueController.php` — link-based upsert
|
||||
- `tests/AbstractApiTestCase.php` — extend factories
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Migration + Entity `CustomField` — `machineContextOnly`
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/CustomField.php` (add property after line 56)
|
||||
|
||||
- [ ] **Step 1: Add `machineContextOnly` property to `CustomField` entity**
|
||||
|
||||
In `src/Entity/CustomField.php`, add after the `$required` property (line 56):
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private bool $machineContextOnly = false;
|
||||
```
|
||||
|
||||
Add getter/setter before the closing `}`:
|
||||
|
||||
```php
|
||||
public function isMachineContextOnly(): bool
|
||||
{
|
||||
return $this->machineContextOnly;
|
||||
}
|
||||
|
||||
public function setMachineContextOnly(bool $machineContextOnly): static
|
||||
{
|
||||
$this->machineContextOnly = $machineContextOnly;
|
||||
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate and adjust migration**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
|
||||
```
|
||||
|
||||
Edit the generated migration to use idempotent SQL:
|
||||
|
||||
```sql
|
||||
ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run migration**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/CustomField.php migrations/
|
||||
git commit -m "feat(custom-fields) : add machineContextOnly flag to CustomField entity"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Entity `CustomFieldValue` — link FKs + inverse collections
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Entity/CustomFieldValue.php` (add after line 67 — `$product` property)
|
||||
- Modify: `src/Entity/MachineComponentLink.php` (add after line 72 — `$productLinks`)
|
||||
- Modify: `src/Entity/MachinePieceLink.php` (add after line 61 — `$productLinks`)
|
||||
|
||||
- [ ] **Step 1: Add FKs to `CustomFieldValue`**
|
||||
|
||||
In `src/Entity/CustomFieldValue.php`, add after the `$product` property (line 67):
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachineComponentLink $machineComponentLink = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachinePieceLink $machinePieceLink = null;
|
||||
```
|
||||
|
||||
Add getters/setters before the closing `}`:
|
||||
|
||||
```php
|
||||
public function getMachineComponentLink(): ?MachineComponentLink
|
||||
{
|
||||
return $this->machineComponentLink;
|
||||
}
|
||||
|
||||
public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
|
||||
{
|
||||
$this->machineComponentLink = $machineComponentLink;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMachinePieceLink(): ?MachinePieceLink
|
||||
{
|
||||
return $this->machinePieceLink;
|
||||
}
|
||||
|
||||
public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
|
||||
{
|
||||
$this->machinePieceLink = $machinePieceLink;
|
||||
|
||||
return $this;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `contextFieldValues` collection to `MachineComponentLink`**
|
||||
|
||||
In `src/Entity/MachineComponentLink.php`, add after the `$productLinks` collection (line 72):
|
||||
|
||||
```php
|
||||
/**
|
||||
* @var Collection<int, CustomFieldValue>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contextFieldValues;
|
||||
```
|
||||
|
||||
In the constructor (line 95), add:
|
||||
```php
|
||||
$this->contextFieldValues = new ArrayCollection();
|
||||
```
|
||||
|
||||
Add getter before the closing `}`:
|
||||
```php
|
||||
/**
|
||||
* @return Collection<int, CustomFieldValue>
|
||||
*/
|
||||
public function getContextFieldValues(): Collection
|
||||
{
|
||||
return $this->contextFieldValues;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `contextFieldValues` collection to `MachinePieceLink`**
|
||||
|
||||
In `src/Entity/MachinePieceLink.php`, add after the `$productLinks` collection (line 61):
|
||||
|
||||
```php
|
||||
/**
|
||||
* @var Collection<int, CustomFieldValue>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contextFieldValues;
|
||||
```
|
||||
|
||||
In the constructor (line 86), add:
|
||||
```php
|
||||
$this->contextFieldValues = new ArrayCollection();
|
||||
```
|
||||
|
||||
Add getter:
|
||||
```php
|
||||
/**
|
||||
* @return Collection<int, CustomFieldValue>
|
||||
*/
|
||||
public function getContextFieldValues(): Collection
|
||||
{
|
||||
return $this->contextFieldValues;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Generate and adjust migration**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:diff
|
||||
```
|
||||
|
||||
Edit migration to use idempotent SQL:
|
||||
```sql
|
||||
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL;
|
||||
ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
|
||||
FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
|
||||
FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid);
|
||||
CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run migration + linter**
|
||||
|
||||
```bash
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate --no-interaction
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Entity/CustomFieldValue.php src/Entity/MachineComponentLink.php src/Entity/MachinePieceLink.php migrations/
|
||||
git commit -m "feat(custom-fields) : add link FKs to CustomFieldValue for machine context"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Test factories — extend helpers
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/AbstractApiTestCase.php:399-461`
|
||||
|
||||
- [ ] **Step 1: Update `createCustomField()` factory**
|
||||
|
||||
In `tests/AbstractApiTestCase.php`, add `bool $machineContextOnly = false` parameter at the end of `createCustomField` (line 399), and add `$cf->setMachineContextOnly($machineContextOnly);` after `$cf->setOrderIndex($orderIndex);` (line 411).
|
||||
|
||||
- [ ] **Step 2: Update `createCustomFieldValue()` factory**
|
||||
|
||||
Add two new nullable parameters at the end of `createCustomFieldValue` (line 432):
|
||||
|
||||
```php
|
||||
?MachineComponentLink $machineComponentLink = null,
|
||||
?MachinePieceLink $machinePieceLink = null,
|
||||
```
|
||||
|
||||
Add the corresponding setter calls **after the closing `}` of the `if (null !== $product)` block** (after line 454, NOT inside it):
|
||||
|
||||
```php
|
||||
if (null !== $machineComponentLink) {
|
||||
$cfv->setMachineComponentLink($machineComponentLink);
|
||||
}
|
||||
if (null !== $machinePieceLink) {
|
||||
$cfv->setMachinePieceLink($machinePieceLink);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/AbstractApiTestCase.php
|
||||
git commit -m "test(custom-fields) : extend factories for machineContextOnly and link params"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `MachineStructureController` — normalize context fields
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/MachineStructureController.php`
|
||||
|
||||
- [ ] **Step 1: Add `machineContextOnly` to all normalization methods**
|
||||
|
||||
In `normalizeCustomFields` (line 601), add to the output array at line 615:
|
||||
```php
|
||||
'machineContextOnly' => $customField->isMachineContextOnly(),
|
||||
```
|
||||
|
||||
In `normalizeCustomFieldDefinitions` (line 838), add to the output array at line 852:
|
||||
```php
|
||||
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||
```
|
||||
|
||||
In `normalizeCustomFieldValues` (line 861), add to the nested `customField` array at line 879:
|
||||
```php
|
||||
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `normalizeContextCustomFieldDefinitions` helper**
|
||||
|
||||
Add a new private method after `normalizeCustomFieldValues`:
|
||||
|
||||
```php
|
||||
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $cf) {
|
||||
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'machineContextOnly' => true,
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `normalizeComponentLinks` to include context fields**
|
||||
|
||||
In `normalizeComponentLinks` (line 622), add `$type` variable and context field keys to the returned array:
|
||||
|
||||
```php
|
||||
private function normalizeComponentLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$composant = $link->getComposant();
|
||||
$parentLink = $link->getParentLink();
|
||||
$type = $composant->getTypeComposant();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `normalizePieceLinks` to include context fields**
|
||||
|
||||
In `normalizePieceLinks` (line 644):
|
||||
|
||||
```php
|
||||
private function normalizePieceLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$parentLink = $link->getParentLink();
|
||||
$type = $piece->getTypePiece();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/MachineStructureController.php
|
||||
git commit -m "feat(custom-fields) : expose context custom fields in machine structure response"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `CustomFieldValueController` — support link-based upsert
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/CustomFieldValueController.php`
|
||||
|
||||
- [ ] **Step 1: Inject link repositories in constructor**
|
||||
|
||||
In the constructor (line 24), add:
|
||||
|
||||
```php
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
```
|
||||
|
||||
Add use statements at the top:
|
||||
```php
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Extend `resolveTarget` to support link entities**
|
||||
|
||||
In `resolveTarget` (line 211), the method applies `strtolower()` on entityType at line 213. The candidate loop and match block must handle this.
|
||||
|
||||
Update the candidate list in the foreach (line 217):
|
||||
```php
|
||||
foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {
|
||||
```
|
||||
|
||||
**IMPORTANT:** The candidate loop assigns `$entityType` in camelCase, but `strtolower()` (line 213) only applies when `entityType` comes from the payload directly. Add a normalization after the loop closes (after the existing `break;` at line 226), before the empty-check at line 229:
|
||||
|
||||
```php
|
||||
$entityType = strtolower($entityType);
|
||||
```
|
||||
|
||||
This ensures both code paths (direct payload `entityType` and candidate loop) deliver lowercase to the match block.
|
||||
|
||||
Update the match block (line 233) — all keys lowercase, but `resolveEntity` returns camelCase type for `applyTarget`:
|
||||
|
||||
```php
|
||||
return match ($entityType) {
|
||||
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
|
||||
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
|
||||
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
|
||||
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
|
||||
'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
|
||||
'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
|
||||
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Extend `applyTarget` for link entities**
|
||||
|
||||
Add two new cases in `applyTarget` (line 252):
|
||||
|
||||
```php
|
||||
case 'machineComponentLink':
|
||||
$value->setMachineComponentLink($entity);
|
||||
$value->setComposant($entity->getComposant());
|
||||
|
||||
break;
|
||||
|
||||
case 'machinePieceLink':
|
||||
$value->setMachinePieceLink($entity);
|
||||
$value->setPiece($entity->getPiece());
|
||||
|
||||
break;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/CustomFieldValueController.php
|
||||
git commit -m "feat(custom-fields) : support link-based upsert in CustomFieldValueController"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Clone support — copy context field values
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/Controller/MachineStructureController.php` (after `cloneProductLinks`, before `flush` in `cloneMachine`)
|
||||
|
||||
- [ ] **Step 1: Add `cloneContextFieldValues` helper method**
|
||||
|
||||
Add after `cloneProductLinks`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Call from `cloneMachine` method**
|
||||
|
||||
In `cloneMachine` (line 113), after `$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);` (line 163) and before `$this->entityManager->flush();` (line 165), add:
|
||||
|
||||
```php
|
||||
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/Controller/MachineStructureController.php
|
||||
git commit -m "feat(custom-fields) : clone context field values on machine clone"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Backend tests
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/Api/Entity/MachineContextCustomFieldTest.php`
|
||||
|
||||
- [ ] **Step 1: Write test class**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Api\Entity;
|
||||
|
||||
use App\Enum\ModelCategory;
|
||||
use App\Tests\AbstractApiTestCase;
|
||||
|
||||
class MachineContextCustomFieldTest extends AbstractApiTestCase
|
||||
{
|
||||
public function testStructureReturnsContextFieldsOnComponentLink(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site A');
|
||||
$modelType = $this->createModelType('Motor', 'MOT', ModelCategory::COMPONENT);
|
||||
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Voltage',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
$normalField = $this->createCustomField(
|
||||
name: 'Serial',
|
||||
type: 'text',
|
||||
typeComposant: $modelType,
|
||||
);
|
||||
|
||||
$machine = $this->createMachine('Machine A', $site);
|
||||
$composant = $this->createComposant('Motor 1', 'MOT-001', $modelType);
|
||||
$link = $this->createMachineComponentLink($machine, $composant);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '220',
|
||||
composant: $composant,
|
||||
machineComponentLink: $link,
|
||||
);
|
||||
|
||||
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
|
||||
$this->assertResponseIsSuccessful();
|
||||
|
||||
$data = $response->toArray();
|
||||
$componentLink = $data['componentLinks'][0];
|
||||
|
||||
$this->assertArrayHasKey('contextCustomFields', $componentLink);
|
||||
$this->assertCount(1, $componentLink['contextCustomFields']);
|
||||
$this->assertSame('Voltage', $componentLink['contextCustomFields'][0]['name']);
|
||||
$this->assertTrue($componentLink['contextCustomFields'][0]['machineContextOnly']);
|
||||
|
||||
$this->assertArrayHasKey('contextCustomFieldValues', $componentLink);
|
||||
$this->assertCount(1, $componentLink['contextCustomFieldValues']);
|
||||
$this->assertSame('220', $componentLink['contextCustomFieldValues'][0]['value']);
|
||||
|
||||
$normalFields = array_filter(
|
||||
$componentLink['composant']['customFields'],
|
||||
fn (array $f) => $f['name'] === 'Serial',
|
||||
);
|
||||
$this->assertCount(1, $normalFields);
|
||||
}
|
||||
|
||||
public function testStructureReturnsContextFieldsOnPieceLink(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site B');
|
||||
$modelType = $this->createModelType('Bearing', 'BRG', ModelCategory::PIECE);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Wear Level',
|
||||
type: 'select',
|
||||
typePiece: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
$contextField->setOptions(['Good', 'Fair', 'Replace']);
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$machine = $this->createMachine('Machine B', $site);
|
||||
$piece = $this->createPiece('Bearing 1', 'BRG-001', $modelType);
|
||||
$link = $this->createMachinePieceLink($machine, $piece);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: 'Fair',
|
||||
piece: $piece,
|
||||
machinePieceLink: $link,
|
||||
);
|
||||
|
||||
$response = $client->request('GET', '/api/machines/'.$machine->getId().'/structure');
|
||||
$data = $response->toArray();
|
||||
|
||||
$pieceLink = $data['pieceLinks'][0];
|
||||
$this->assertCount(1, $pieceLink['contextCustomFields']);
|
||||
$this->assertSame('Wear Level', $pieceLink['contextCustomFields'][0]['name']);
|
||||
$this->assertCount(1, $pieceLink['contextCustomFieldValues']);
|
||||
$this->assertSame('Fair', $pieceLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testUpsertContextFieldValueViaComponentLink(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site C');
|
||||
$modelType = $this->createModelType('Pump', 'PMP', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Flow Rate',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$machine = $this->createMachine('Machine C', $site);
|
||||
$composant = $this->createComposant('Pump 1', 'PMP-001', $modelType);
|
||||
$link = $this->createMachineComponentLink($machine, $composant);
|
||||
|
||||
$response = $client->request('POST', '/api/custom-fields/values/upsert', [
|
||||
'json' => [
|
||||
'customFieldId' => $contextField->getId(),
|
||||
'machineComponentLinkId' => $link->getId(),
|
||||
'value' => '380',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
$this->assertSame('380', $data['value']);
|
||||
}
|
||||
|
||||
public function testSameComposantDifferentValuesPerMachine(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site D');
|
||||
$modelType = $this->createModelType('Valve', 'VLV', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Pressure',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$machineA = $this->createMachine('Machine A', $site);
|
||||
$machineB = $this->createMachine('Machine B', $site);
|
||||
$composant = $this->createComposant('Valve 1', 'VLV-001', $modelType);
|
||||
|
||||
$linkA = $this->createMachineComponentLink($machineA, $composant);
|
||||
$linkB = $this->createMachineComponentLink($machineB, $composant);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '100',
|
||||
composant: $composant,
|
||||
machineComponentLink: $linkA,
|
||||
);
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '200',
|
||||
composant: $composant,
|
||||
machineComponentLink: $linkB,
|
||||
);
|
||||
|
||||
$dataA = $client->request('GET', '/api/machines/'.$machineA->getId().'/structure')->toArray();
|
||||
$this->assertSame('100', $dataA['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
|
||||
|
||||
$dataB = $client->request('GET', '/api/machines/'.$machineB->getId().'/structure')->toArray();
|
||||
$this->assertSame('200', $dataB['componentLinks'][0]['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
|
||||
public function testMachineContextOnlyFieldSerialization(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site E');
|
||||
$modelType = $this->createModelType('Sensor', 'SNS', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'Calibration Date',
|
||||
type: 'date',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$response = $client->request('GET', '/api/custom_fields/'.$contextField->getId());
|
||||
$this->assertResponseIsSuccessful();
|
||||
$data = $response->toArray();
|
||||
$this->assertTrue($data['machineContextOnly']);
|
||||
}
|
||||
|
||||
public function testCloneMachineCopiesContextFieldValues(): void
|
||||
{
|
||||
$client = $this->createGestionnaireClient();
|
||||
|
||||
$site = $this->createSite('Site F');
|
||||
$modelType = $this->createModelType('Motor Clone', 'MOTC', ModelCategory::COMPONENT);
|
||||
$contextField = $this->createCustomField(
|
||||
name: 'RPM Setting',
|
||||
type: 'number',
|
||||
typeComposant: $modelType,
|
||||
machineContextOnly: true,
|
||||
);
|
||||
|
||||
$source = $this->createMachine('Source Machine', $site);
|
||||
$composant = $this->createComposant('Motor C', 'MOTC-001', $modelType);
|
||||
$link = $this->createMachineComponentLink($source, $composant);
|
||||
|
||||
$this->createCustomFieldValue(
|
||||
customField: $contextField,
|
||||
value: '3000',
|
||||
composant: $composant,
|
||||
machineComponentLink: $link,
|
||||
);
|
||||
|
||||
$response = $client->request('POST', '/api/machines/'.$source->getId().'/clone', [
|
||||
'json' => [
|
||||
'name' => 'Cloned Machine',
|
||||
'siteId' => $site->getId(),
|
||||
],
|
||||
]);
|
||||
|
||||
$this->assertResponseStatusCodeSame(201);
|
||||
$data = $response->toArray();
|
||||
|
||||
$clonedLink = $data['componentLinks'][0] ?? null;
|
||||
$this->assertNotNull($clonedLink);
|
||||
$this->assertCount(1, $clonedLink['contextCustomFieldValues']);
|
||||
$this->assertSame('3000', $clonedLink['contextCustomFieldValues'][0]['value']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests**
|
||||
|
||||
```bash
|
||||
make test FILES=tests/Api/Entity/MachineContextCustomFieldTest.php
|
||||
```
|
||||
|
||||
Expected: All 6 tests pass.
|
||||
|
||||
- [ ] **Step 3: Run linter**
|
||||
|
||||
```bash
|
||||
make php-cs-fixer-allow-risky
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run full test suite**
|
||||
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
Expected: All existing tests still pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/Api/Entity/MachineContextCustomFieldTest.php
|
||||
git commit -m "test(custom-fields) : add tests for machine context custom fields"
|
||||
```
|
||||
@@ -0,0 +1,404 @@
|
||||
# Machine Context Custom Fields — Frontend 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.
|
||||
> **Parallel plan:** This is the frontend half. The backend plan is at `2026-04-02-machine-context-fields-backend.md`. Both can run in parallel on separate worktrees — they share no files. Frontend tests requiring the API will need the backend done first.
|
||||
|
||||
**Goal:** Add `machineContextOnly` toggle in structure editors, filter context fields from standalone pages, and display/edit them in the machine detail view.
|
||||
|
||||
**Architecture:** Structure editors get a checkbox per field. The machine-detail transform propagates `contextCustomFields`/`contextCustomFieldValues` from the API link response onto the component/piece objects. Standalone entity views filter these out. Machine view displays them in a separate "Champs contextuels" section using the existing `CustomFieldDisplay` component, saving via upsert with the link ID.
|
||||
|
||||
**Tech Stack:** Nuxt 4, Vue 3 Composition API, TypeScript, DaisyUI 5
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-02-machine-context-custom-fields-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Modify
|
||||
- `frontend/app/shared/types/inventory.ts` — add `machineContextOnly` to custom field types
|
||||
- `frontend/app/components/PieceModelStructureEditor.vue` — add checkbox toggle
|
||||
- `frontend/app/composables/usePieceStructureEditorLogic.ts` — add default in `createEmptyField()`
|
||||
- `frontend/app/components/StructureNodeEditor.vue` — add checkbox toggle
|
||||
- `frontend/app/composables/useStructureNodeCrud.ts` — add default in `addCustomField()`
|
||||
- `frontend/app/composables/useEntityCustomFields.ts` — filter out `machineContextOnly` fields
|
||||
- `frontend/app/composables/useMachineDetailCustomFields.ts` — propagate context fields, filter from normal merge
|
||||
- `frontend/app/components/ComponentItem.vue` — display context custom fields section
|
||||
- `frontend/app/components/PieceItem.vue` — display context custom fields section
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Types — add `machineContextOnly`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/shared/types/inventory.ts`
|
||||
|
||||
- [ ] **Step 1: Add `machineContextOnly` to `ComponentModelCustomField`**
|
||||
|
||||
In the `ComponentModelCustomField` interface (around line 14), add:
|
||||
|
||||
```typescript
|
||||
machineContextOnly?: boolean
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `machineContextOnly` to `PieceModelCustomField`**
|
||||
|
||||
In the `PieceModelCustomField` interface (around line 65), add:
|
||||
|
||||
```typescript
|
||||
machineContextOnly?: boolean
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add app/shared/types/inventory.ts
|
||||
git commit -m "feat(custom-fields) : add machineContextOnly to custom field types"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Structure editors — add toggle
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/PieceModelStructureEditor.vue:122-125`
|
||||
- Modify: `frontend/app/composables/usePieceStructureEditorLogic.ts:283-290`
|
||||
- Modify: `frontend/app/components/StructureNodeEditor.vue:121-125`
|
||||
- Modify: `frontend/app/composables/useStructureNodeCrud.ts:49-62`
|
||||
|
||||
- [ ] **Step 1: Add toggle in `PieceModelStructureEditor.vue`**
|
||||
|
||||
After the "Obligatoire" checkbox block (line 125, after `</div>` closing the required checkbox), add:
|
||||
|
||||
```vue
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
|
||||
Contexte machine uniquement
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update `usePieceStructureEditorLogic.ts` — 3 functions**
|
||||
|
||||
**a) `createEmptyField`** (line 283) — add `machineContextOnly: false` to the returned object:
|
||||
|
||||
```typescript
|
||||
const createEmptyField = (orderIndex: number): EditorField => ({
|
||||
uid: createUid('field'),
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
machineContextOnly: false,
|
||||
orderIndex,
|
||||
})
|
||||
```
|
||||
|
||||
**b) `toEditorField`** (line 78-91) — add `machineContextOnly` to the returned object, after the `orderIndex` line (line 90):
|
||||
|
||||
```typescript
|
||||
machineContextOnly: Boolean(input?.machineContextOnly),
|
||||
```
|
||||
|
||||
**c) `buildPayload`** (line 160-165) — add `machineContextOnly` to the `payload` object after `orderIndex` (line 164):
|
||||
|
||||
```typescript
|
||||
machineContextOnly: Boolean(field.machineContextOnly),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add toggle in `StructureNodeEditor.vue`**
|
||||
|
||||
After the "Obligatoire" checkbox closing `</div>` (line 123) and **before** the `<textarea>` for select options (line 124), add:
|
||||
|
||||
```vue
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
|
||||
Contexte machine uniquement
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update `addCustomField` in `useStructureNodeCrud.ts`**
|
||||
|
||||
In `addCustomField` (line 49), add `machineContextOnly: false` to the pushed object (line 53-60):
|
||||
|
||||
```typescript
|
||||
fields.push({
|
||||
name: '',
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
options: [],
|
||||
machineContextOnly: false,
|
||||
orderIndex: nextIndex,
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add .
|
||||
git commit -m "feat(custom-fields) : add machineContextOnly toggle in structure editors"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Filter context fields on standalone pages + machine-detail transform
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/composables/useEntityCustomFields.ts:42-49`
|
||||
- Modify: `frontend/app/composables/useMachineDetailCustomFields.ts:141-154,241-256`
|
||||
|
||||
- [ ] **Step 1: Filter `machineContextOnly` from `displayedCustomFields` in `useEntityCustomFields.ts`**
|
||||
|
||||
Update the `displayedCustomFields` computed (line 42):
|
||||
|
||||
```typescript
|
||||
const displayedCustomFields = computed(() =>
|
||||
dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(
|
||||
definitionSources.value,
|
||||
entity().customFieldValues,
|
||||
),
|
||||
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Filter `machineContextOnly` from normal customFields in machine-detail transform and propagate context data**
|
||||
|
||||
In `frontend/app/composables/useMachineDetailCustomFields.ts`:
|
||||
|
||||
**For pieces** — In `transformCustomFields` (line 70), the returned object is built at line 141. Replace the `customFields,` line (line 143) with:
|
||||
|
||||
```typescript
|
||||
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
|
||||
contextCustomFields: piece.contextCustomFields ?? [],
|
||||
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
|
||||
```
|
||||
|
||||
**For components** — In `transformComponentCustomFields` (line 158), the returned object is built at line 241. Replace the `customFields,` line (line 243) with:
|
||||
|
||||
```typescript
|
||||
customFields: customFields.filter((f: AnyRecord) => !f.machineContextOnly && !f.customField?.machineContextOnly),
|
||||
contextCustomFields: component.contextCustomFields ?? [],
|
||||
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add .
|
||||
git commit -m "feat(custom-fields) : filter machineContextOnly from standalone and machine-detail views"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Display context fields in machine view — `ComponentItem.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/ComponentItem.vue`
|
||||
|
||||
Context fields are on the `component` object (set by the transform in Task 3), not as separate props.
|
||||
|
||||
- [ ] **Step 1: Add template section**
|
||||
|
||||
After the existing `CustomFieldDisplay` block (line 195), add:
|
||||
|
||||
```vue
|
||||
<!-- Context custom fields (machine-specific) -->
|
||||
<div v-if="mergedContextFields.length" class="mt-4">
|
||||
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
|
||||
Champs contextuels
|
||||
</h4>
|
||||
<CustomFieldDisplay
|
||||
:fields="mergedContextFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
:columns="2"
|
||||
@field-blur="updateContextCustomField"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add imports and script logic**
|
||||
|
||||
**IMPORTANT:** `ComponentItem.vue` uses `<script setup>` WITHOUT `lang="ts"`. Do NOT use TypeScript annotations (`: any`, `: string`, etc.) in any code added to this file.
|
||||
|
||||
Add these imports (they are NOT already present in the component):
|
||||
|
||||
```javascript
|
||||
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
```
|
||||
|
||||
Add after the existing `useEntityCustomFields` block (around line 348):
|
||||
|
||||
```javascript
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const mergedContextFields = computed(() => {
|
||||
const definitions = props.component?.contextCustomFields ?? []
|
||||
const values = props.component?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
})
|
||||
|
||||
const updateContextCustomField = async (field) => {
|
||||
const linkId = props.component?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = field.customFieldId || field.customField?.id
|
||||
if (!customFieldId) return
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
customFieldId,
|
||||
'machineComponentLink',
|
||||
linkId,
|
||||
field.value ?? '',
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ contextuel`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add app/components/ComponentItem.vue
|
||||
git commit -m "feat(custom-fields) : display context custom fields in ComponentItem"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Display context fields in machine view — `PieceItem.vue`
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/app/components/PieceItem.vue`
|
||||
|
||||
- [ ] **Step 1: Add template section**
|
||||
|
||||
After the existing `CustomFieldDisplay` block (line 236), add:
|
||||
|
||||
```vue
|
||||
<!-- Context custom fields (machine-specific) -->
|
||||
<div v-if="mergedContextFields.length" class="mt-4">
|
||||
<h4 class="text-xs font-semibold text-base-content/70 mb-2">
|
||||
Champs contextuels
|
||||
</h4>
|
||||
<CustomFieldDisplay
|
||||
:fields="mergedContextFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
:columns="2"
|
||||
@field-blur="updateContextCustomField"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add imports and script logic**
|
||||
|
||||
**IMPORTANT:** `PieceItem.vue` uses `<script setup>` WITHOUT `lang="ts"`. Do NOT use TypeScript annotations in any code added to this file.
|
||||
|
||||
Add these imports (they are NOT already present in the component):
|
||||
|
||||
```javascript
|
||||
import { mergeFieldDefinitionsWithValues, dedupeMergedFields } from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
```
|
||||
|
||||
Add after the existing `useEntityCustomFields` block (around line 366):
|
||||
|
||||
```javascript
|
||||
const { upsertCustomFieldValue } = useCustomFields()
|
||||
const { showSuccess, showError } = useToast()
|
||||
|
||||
const mergedContextFields = computed(() => {
|
||||
const definitions = props.piece?.contextCustomFields ?? []
|
||||
const values = props.piece?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
})
|
||||
|
||||
const updateContextCustomField = async (field) => {
|
||||
const linkId = props.piece?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = field.customFieldId || field.customField?.id
|
||||
if (!customFieldId) return
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
customFieldId,
|
||||
'machinePieceLink',
|
||||
linkId,
|
||||
field.value ?? '',
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
showSuccess(`Champ contextuel "${field.name || field.customField?.name}" mis à jour`)
|
||||
} else {
|
||||
showError(`Erreur lors de la mise à jour du champ contextuel`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run lint + typecheck**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run lint:fix && npx nuxi typecheck
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd frontend && git add app/components/PieceItem.vue
|
||||
git commit -m "feat(custom-fields) : display context custom fields in PieceItem"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Frontend build verification
|
||||
|
||||
- [ ] **Step 1: Run full build**
|
||||
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
Expected: Build succeeds with no errors.
|
||||
|
||||
- [ ] **Step 2: Final commit — update submodule pointer (from main repo)**
|
||||
|
||||
```bash
|
||||
cd /home/matthieu/dev_malio/Inventory
|
||||
git add frontend
|
||||
git commit -m "chore : update frontend submodule for context custom fields"
|
||||
```
|
||||
@@ -18,7 +18,7 @@ L'utilisateur veut pouvoir sélectionner plusieurs sites simultanément.
|
||||
- Quand **une ou plusieurs** sont cochées → filtre sur ces sites uniquement.
|
||||
|
||||
### Changements techniques
|
||||
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue`
|
||||
**Fichier** : `frontend/app/pages/machines/index.vue`
|
||||
|
||||
- **Réactivité** : utiliser `reactive(new Set())` (Vue 3.4+ supporte nativement les mutations `add`/`delete`/`has` sur un Set réactif). Pas de `.value` nécessaire.
|
||||
- **Note** : le fichier utilise `<script setup>` sans `lang="ts"` — ne pas utiliser d'annotations TypeScript comme `Set<string>`.
|
||||
@@ -36,7 +36,7 @@ Les machines s'affichent dans l'ordre retourné par l'API, sans tri. L'utilisate
|
||||
Ajouter un `.sort()` avec `localeCompare('fr')` à la fin du computed `filteredMachines`.
|
||||
|
||||
### Changements techniques
|
||||
**Fichier** : `Inventory_frontend/app/pages/machines/index.vue`
|
||||
**Fichier** : `frontend/app/pages/machines/index.vue`
|
||||
|
||||
- Dans le computed `filteredMachines`, ajouter avant le `return` :
|
||||
```js
|
||||
@@ -67,9 +67,9 @@ Créer une **Extension Doctrine** (`SearchByNameOrReferenceExtension`) qui inter
|
||||
- **Pas de conflit** avec le `SearchFilter` existant : le paramètre `q` n'est pas enregistré comme propriété de `SearchFilter`, donc il sera ignoré par celui-ci. Les filtres `name` et `reference` restent disponibles pour d'autres usages.
|
||||
|
||||
**Frontend — 3 fichiers** (dans la fonction `loadXxx`, remplacer l'appel `params.set('name', search.trim())`) :
|
||||
- `Inventory_frontend/app/composables/usePieces.ts` → `params.set('q', search.trim())`
|
||||
- `Inventory_frontend/app/composables/useComposants.ts` → idem
|
||||
- `Inventory_frontend/app/composables/useProducts.ts` → idem
|
||||
- `frontend/app/composables/usePieces.ts` → `params.set('q', search.trim())`
|
||||
- `frontend/app/composables/useComposants.ts` → idem
|
||||
- `frontend/app/composables/useProducts.ts` → idem
|
||||
|
||||
---
|
||||
|
||||
@@ -77,11 +77,11 @@ Créer une **Extension Doctrine** (`SearchByNameOrReferenceExtension`) qui inter
|
||||
|
||||
| Fichier | Changement |
|
||||
|---------|-----------|
|
||||
| `Inventory_frontend/app/pages/machines/index.vue` | Checkboxes sites + tri alphabétique |
|
||||
| `frontend/app/pages/machines/index.vue` | Checkboxes sites + tri alphabétique |
|
||||
| `src/Doctrine/SearchByNameOrReferenceExtension.php` | **Nouveau** — Extension Doctrine OR search |
|
||||
| `Inventory_frontend/app/composables/usePieces.ts` | `name` → `q` |
|
||||
| `Inventory_frontend/app/composables/useComposants.ts` | `name` → `q` |
|
||||
| `Inventory_frontend/app/composables/useProducts.ts` | `name` → `q` |
|
||||
| `frontend/app/composables/usePieces.ts` | `name` → `q` |
|
||||
| `frontend/app/composables/useComposants.ts` | `name` → `q` |
|
||||
| `frontend/app/composables/useProducts.ts` | `name` → `q` |
|
||||
|
||||
## Hors scope
|
||||
- La page Parc Machines cherche **déjà** sur nom ET référence côté frontend (filtrage client-side). Pas de changement nécessaire.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Date** : 2026-03-31
|
||||
**Scope** : Frontend uniquement (pas de changement backend)
|
||||
**Fichier impacté** : `Inventory_frontend/app/components/model-types/ModelTypeForm.vue`
|
||||
**Fichier impacté** : `frontend/app/components/model-types/ModelTypeForm.vue`
|
||||
|
||||
## Problème
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# Machine Context Custom Fields
|
||||
|
||||
**Date** : 2026-04-02
|
||||
**Statut** : Validé
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre de définir des champs personnalisés sur un ModelType (catégorie de pièce/composant) qui ne s'affichent et ne sont remplissables que lorsque l'item est lié à une machine. Les valeurs sont propres au lien machine (une même pièce dans deux machines peut avoir des valeurs différentes).
|
||||
|
||||
## Périmètre
|
||||
|
||||
- **Entités concernées** : Composants et Pièces (pas Produits)
|
||||
- **Définition** : Sur le ModelType, avec un flag `machineContextOnly`
|
||||
- **Valeurs** : Stockées par lien (`MachineComponentLink` / `MachinePieceLink`)
|
||||
- **Affichage** : Uniquement dans la vue machine, pas sur les fiches autonomes
|
||||
|
||||
## Architecture
|
||||
|
||||
### Approche retenue
|
||||
|
||||
Extension des entités existantes `CustomField` et `CustomFieldValue` avec :
|
||||
- Un flag de filtrage sur la définition
|
||||
- Des FK vers les entités de lien pour les valeurs
|
||||
|
||||
### Alternatives écartées
|
||||
|
||||
- **Entités séparées** (`MachineContextField` / `MachineContextFieldValue`) — trop de duplication de logique
|
||||
- **JSON sur les liens** — contraire au projet de normalisation JSON→tables en cours
|
||||
|
||||
## Backend
|
||||
|
||||
### 1. Entité `CustomField`
|
||||
|
||||
Nouveau champ :
|
||||
|
||||
```php
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['customField:read', 'customField:write'])]
|
||||
private bool $machineContextOnly = false;
|
||||
```
|
||||
|
||||
Getter/setter associés.
|
||||
|
||||
### 2. Entité `CustomFieldValue`
|
||||
|
||||
Nouvelles FK nullable :
|
||||
|
||||
```php
|
||||
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
private ?MachineComponentLink $machineComponentLink = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(nullable: true)]
|
||||
private ?MachinePieceLink $machinePieceLink = null;
|
||||
```
|
||||
|
||||
Contrainte métier : quand `machineComponentLink` est set, `composant` reste aussi set (pour faciliter les queries par composant). Idem pour `machinePieceLink` + `piece`.
|
||||
|
||||
### 3. Entités `MachineComponentLink` / `MachinePieceLink`
|
||||
|
||||
Nouvelle collection :
|
||||
|
||||
```php
|
||||
#[ORM\OneToMany(targetEntity: CustomFieldValue::class, mappedBy: 'machineComponentLink', cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contextFieldValues;
|
||||
```
|
||||
|
||||
Idem sur `MachinePieceLink` avec `mappedBy: 'machinePieceLink'`.
|
||||
|
||||
### 4. Migration
|
||||
|
||||
```sql
|
||||
ALTER TABLE custom_field ADD machine_context_only BOOLEAN DEFAULT false NOT NULL;
|
||||
|
||||
ALTER TABLE custom_field_value ADD machine_component_link_id VARCHAR(36) DEFAULT NULL;
|
||||
ALTER TABLE custom_field_value ADD machine_piece_link_id VARCHAR(36) DEFAULT NULL;
|
||||
|
||||
ALTER TABLE custom_field_value
|
||||
ADD CONSTRAINT fk_cfv_machine_component_link
|
||||
FOREIGN KEY (machine_component_link_id) REFERENCES machine_component_link(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE custom_field_value
|
||||
ADD CONSTRAINT fk_cfv_machine_piece_link
|
||||
FOREIGN KEY (machine_piece_link_id) REFERENCES machine_piece_link(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX idx_cfv_machine_component_link ON custom_field_value(machine_component_link_id);
|
||||
CREATE INDEX idx_cfv_machine_piece_link ON custom_field_value(machine_piece_link_id);
|
||||
```
|
||||
|
||||
### 5. `MachineStructureController`
|
||||
|
||||
Dans `normalizeComposant()` et `normalizePiece()` :
|
||||
- Récupérer les `CustomField` du ModelType où `machineContextOnly = true`
|
||||
- Récupérer les `CustomFieldValue` liées au lien via `machineComponentLink` / `machinePieceLink`
|
||||
- Ajouter dans la réponse :
|
||||
|
||||
```json
|
||||
{
|
||||
"contextCustomFields": [{ "id": "...", "name": "...", "type": "...", ... }],
|
||||
"contextCustomFieldValues": [{ "id": "...", "value": "...", "customField": {...} }]
|
||||
}
|
||||
```
|
||||
|
||||
Séparé des `customFields` / `customFieldValues` globaux existants.
|
||||
|
||||
### 6. `CustomFieldValueController`
|
||||
|
||||
L'upsert existant est étendu pour accepter `machineComponentLink` ou `machinePieceLink` dans le body. Le controller vérifie que si le `CustomField` a `machineContextOnly = true`, un lien machine est obligatoire.
|
||||
|
||||
### 7. Clonage machine
|
||||
|
||||
`MachineStructureController::cloneCustomFields()` doit aussi cloner les `contextFieldValues` des liens, en les rattachant aux nouveaux liens créés lors du clone.
|
||||
|
||||
## Frontend
|
||||
|
||||
### 1. Page ModelType — Définition des champs
|
||||
|
||||
Dans l'UI d'édition des custom fields d'un ModelType, ajouter un **toggle/checkbox** "Contexte machine uniquement" sur chaque définition de champ. Cela set `machineContextOnly: true` lors de la sauvegarde.
|
||||
|
||||
Concerne les custom fields des catégories COMPONENT et PIECE (pas PRODUCT, hors périmètre).
|
||||
|
||||
### 2. Vue machine — `ComponentItem.vue` / `PieceItem.vue`
|
||||
|
||||
Nouvelle section "Champs contextuels" affichée sous les custom fields existants :
|
||||
- Reçoit `contextCustomFields` et `contextCustomFieldValues` en props
|
||||
- Réutilise le composant `CustomFieldDisplay.vue` existant
|
||||
- Mode édition : sur blur/change, appel upsert via `CustomFieldValueController` avec le `machineComponentLinkId` ou `machinePieceLinkId`
|
||||
|
||||
### 3. Fiches autonomes pièce/composant
|
||||
|
||||
Filtrer les champs `machineContextOnly = true` pour ne pas les afficher :
|
||||
- Dans `useEntityCustomFields` : exclure ces champs du `displayedCustomFields`
|
||||
- Dans `useMachineDetailCustomFields` : séparer les champs normaux des champs contextuels
|
||||
|
||||
### 4. Transformation des données (`useMachineDetailCustomFields`)
|
||||
|
||||
`transformComponentCustomFields()` et `transformCustomFields()` :
|
||||
- Extraire `contextCustomFields` / `contextCustomFieldValues` depuis la réponse structure
|
||||
- Les passer en propriétés séparées sur l'objet transformé
|
||||
|
||||
## Tests
|
||||
|
||||
### Backend
|
||||
- Test unitaire : `CustomField` avec `machineContextOnly = true` est correctement sérialisé
|
||||
- Test API : upsert d'un `CustomFieldValue` avec `machineComponentLink` fonctionne
|
||||
- Test API : upsert d'un `CustomFieldValue` contextuel sans lien machine retourne une erreur
|
||||
- Test API : `/api/machines/{id}/structure` retourne les `contextCustomFields` et `contextCustomFieldValues`
|
||||
- Test API : clone machine copie les valeurs contextuelles
|
||||
|
||||
### Frontend
|
||||
- Typecheck : 0 erreurs après modifications
|
||||
- Vérification manuelle : les champs contextuels apparaissent dans la vue machine
|
||||
- Vérification manuelle : les champs contextuels n'apparaissent pas sur les fiches autonomes
|
||||
@@ -12,6 +12,7 @@
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('delete')"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,5 +44,5 @@ defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
</script>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
|
||||
<!-- Component Header -->
|
||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg cursor-pointer" :class="component.pendingEntity ? 'bg-error/10 border border-error' : 'bg-base-200'" @click="toggleCollapse">
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
@@ -22,9 +22,18 @@
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-sm font-semibold text-base-content truncate">
|
||||
<h3 class="text-sm font-semibold truncate" :class="component.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||
{{ component.name }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="component.pendingEntity"
|
||||
type="button"
|
||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||
title="Cliquer pour associer un item"
|
||||
@click.stop="$emit('fill-entity', component.linkId, component.modelTypeId)"
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<span v-if="component.reference" class="badge badge-outline badge-xs">{{ component.reference }}</span>
|
||||
<span v-if="component.prix" class="badge badge-primary badge-xs">{{ component.prix }}€</span>
|
||||
</div>
|
||||
@@ -54,7 +63,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Expanded content -->
|
||||
<div v-show="!isCollapsed" class="mt-3 space-y-4 pl-7">
|
||||
<div v-show="!isCollapsed && !component.pendingEntity" class="mt-3 space-y-4 pl-7">
|
||||
<!-- Info fields -->
|
||||
<div v-if="isEditMode" class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="form-control">
|
||||
@@ -191,9 +200,27 @@
|
||||
: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">
|
||||
@@ -241,6 +268,7 @@
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,6 +304,7 @@
|
||||
@update="$emit('update', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,7 +328,6 @@ import {
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentIcon,
|
||||
@@ -308,6 +336,12 @@ import {
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
import {
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
resolveCustomFieldId,
|
||||
resolveFieldId,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
|
||||
const props = defineProps({
|
||||
component: { type: Object, required: true },
|
||||
@@ -317,7 +351,7 @@ const props = defineProps({
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
|
||||
// --- Shared composables ---
|
||||
const {
|
||||
@@ -347,6 +381,34 @@ const {
|
||||
updateCustomField: updateComponentCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
const mergedContextFields = computed(() => {
|
||||
const definitions = props.component?.contextCustomFields ?? []
|
||||
const values = props.component?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
})
|
||||
|
||||
const queueContextCustomFieldUpdate = (field, value) => {
|
||||
const linkId = props.component?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = resolveCustomFieldId(field)
|
||||
const customFieldValueId = resolveFieldId(field)
|
||||
if (!customFieldId && !customFieldValueId) return
|
||||
|
||||
field.value = value
|
||||
emit('custom-field-update', {
|
||||
entityType: 'machineComponentLink',
|
||||
entityId: linkId,
|
||||
fieldId: customFieldId,
|
||||
customFieldValueId,
|
||||
value: value ?? '',
|
||||
fieldName: field.name || field.customField?.name || 'Champ contextuel',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Document edit modal ---
|
||||
const editingDocument = ref(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
/>
|
||||
|
||||
<!-- Piece Header (collapsible, same pattern as ComponentItem) -->
|
||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||
<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 class="flex items-start gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -28,8 +28,18 @@
|
||||
<span class="sr-only">{{ isCollapsed ? 'Déplier' : 'Replier' }} la pièce</span>
|
||||
</button>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="text-lg font-semibold">
|
||||
<h3 class="text-lg font-semibold" :class="{ 'text-error': piece._emptySlot || piece.pendingEntity }">
|
||||
{{ pieceData.name }}
|
||||
<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"
|
||||
@@ -76,7 +86,7 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed" class="space-y-4">
|
||||
<div v-show="!isCollapsed && !piece.pendingEntity" class="space-y-4">
|
||||
<div class="p-4 bg-base-100 border border-base-200 rounded-lg">
|
||||
<div class="space-y-2 text-sm">
|
||||
<div v-if="isEditMode" class="form-control">
|
||||
@@ -231,10 +241,28 @@
|
||||
<CustomFieldDisplay
|
||||
:fields="displayedCustomFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
title="Champs personnalisés item"
|
||||
:editable="false"
|
||||
@field-input="handleCustomFieldInput"
|
||||
@field-blur="handleCustomFieldBlur"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
@@ -294,6 +322,9 @@ import {
|
||||
import {
|
||||
resolveFieldId,
|
||||
resolveFieldReadOnly,
|
||||
resolveCustomFieldId,
|
||||
mergeFieldDefinitionsWithValues,
|
||||
dedupeMergedFields,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
@@ -307,7 +338,7 @@ const props = defineProps({
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete', 'fill-entity'])
|
||||
|
||||
// --- Local reactive data for editing ---
|
||||
const pieceData = reactive({
|
||||
@@ -365,6 +396,34 @@ const {
|
||||
updateCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||
|
||||
const mergedContextFields = computed(() => {
|
||||
const definitions = props.piece?.contextCustomFields ?? []
|
||||
const values = props.piece?.contextCustomFieldValues ?? []
|
||||
if (!definitions.length && !values.length) return []
|
||||
return dedupeMergedFields(
|
||||
mergeFieldDefinitionsWithValues(definitions, values),
|
||||
)
|
||||
})
|
||||
|
||||
const queueContextCustomFieldUpdate = (field, value) => {
|
||||
const linkId = props.piece?.linkId
|
||||
if (!linkId || !field) return
|
||||
|
||||
const customFieldId = resolveCustomFieldId(field)
|
||||
const customFieldValueId = resolveFieldId(field)
|
||||
if (!customFieldId && !customFieldValueId) return
|
||||
|
||||
field.value = value
|
||||
emit('custom-field-update', {
|
||||
entityType: 'machinePieceLink',
|
||||
entityId: linkId,
|
||||
fieldId: customFieldId,
|
||||
customFieldValueId,
|
||||
value: value ?? '',
|
||||
fieldName: field.name || field.customField?.name || 'Champ contextuel',
|
||||
})
|
||||
}
|
||||
|
||||
// --- Document edit modal ---
|
||||
const editingDocument = ref(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
@@ -124,6 +124,11 @@
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
|
||||
Contexte machine uniquement
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
|
||||
@@ -121,6 +121,10 @@
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
|
||||
Obligatoire
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.machineContextOnly" type="checkbox" class="checkbox checkbox-xs">
|
||||
Contexte machine uniquement
|
||||
</div>
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="fields.length"
|
||||
class="mt-4 pt-4 border-t border-base-200"
|
||||
:class="containerClass"
|
||||
>
|
||||
<h5 class="text-sm font-medium text-base-content/80 mb-3">
|
||||
Champs personnalisés
|
||||
<h5 v-if="showHeader" class="text-sm font-medium text-base-content/80 mb-3">
|
||||
{{ title }}
|
||||
</h5>
|
||||
<div :class="layoutClass">
|
||||
<div
|
||||
@@ -23,7 +23,7 @@
|
||||
</label>
|
||||
|
||||
<!-- Mode édition -->
|
||||
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
||||
<template v-if="isFieldEditable(field)">
|
||||
<!-- Champ de type TEXT -->
|
||||
<input
|
||||
v-if="resolveFieldType(field) === 'text'"
|
||||
@@ -142,6 +142,11 @@ const props = defineProps<{
|
||||
fields: any[]
|
||||
isEditMode: boolean
|
||||
columns?: 1 | 2
|
||||
title?: string
|
||||
showHeader?: boolean
|
||||
withTopBorder?: boolean
|
||||
editable?: boolean
|
||||
emitBlur?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -155,6 +160,20 @@ const layoutClass = computed(() =>
|
||||
: 'space-y-3',
|
||||
)
|
||||
|
||||
const title = computed(() => props.title ?? 'Champs personnalisés')
|
||||
const showHeader = computed(() => props.showHeader ?? true)
|
||||
const containerClass = computed(() =>
|
||||
props.withTopBorder === false
|
||||
? ''
|
||||
: 'mt-4 pt-4 border-t border-base-200',
|
||||
)
|
||||
const editable = computed(() => props.editable ?? true)
|
||||
const emitBlur = computed(() => props.emitBlur ?? true)
|
||||
|
||||
function isFieldEditable(field: any) {
|
||||
return props.isEditMode && editable.value && !resolveFieldReadOnly(field)
|
||||
}
|
||||
|
||||
function onInput(field: any, value: string) {
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
@@ -164,10 +183,14 @@ function onBooleanChange(field: any, checked: boolean) {
|
||||
const value = checked ? 'true' : 'false'
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
emit('field-blur', field)
|
||||
if (emitBlur.value) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur(field: any) {
|
||||
emit('field-blur', field)
|
||||
if (emitBlur.value) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedTypeName && !selectedEntityId && !loadingEntities" class="bg-warning/10 border border-warning rounded-lg p-3 mb-4">
|
||||
<p class="text-sm text-warning font-medium">
|
||||
Aucun item sélectionné — la catégorie sera ajoutée avec le statut "À remplir".
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Summary of selection -->
|
||||
<div v-if="selectedEntitySummary" class="bg-base-200 rounded-lg p-3 mb-4">
|
||||
<p class="text-sm font-medium">{{ selectedEntitySummary.name }}</p>
|
||||
@@ -64,10 +70,10 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedEntityId"
|
||||
:disabled="!selectedTypeId"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
Ajouter
|
||||
{{ selectedEntityId ? 'Ajouter' : 'Ajouter (catégorie seule)' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,11 +96,12 @@ type EntityKind = 'component' | 'piece' | 'product'
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
entityKind: EntityKind
|
||||
prefillTypeId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [entityId: string]
|
||||
confirm: [payload: { entityId?: string; modelTypeId: string; modelTypeName: string }]
|
||||
}>()
|
||||
|
||||
const selectedTypeId = ref('')
|
||||
@@ -166,6 +173,10 @@ watch(() => props.open, async (isOpen) => {
|
||||
if (props.entityKind === 'component') await loadComponentTypes()
|
||||
else if (props.entityKind === 'piece') await loadPieceTypes()
|
||||
else await loadProductTypes()
|
||||
|
||||
if (props.prefillTypeId) {
|
||||
selectedTypeId.value = props.prefillTypeId
|
||||
}
|
||||
})
|
||||
|
||||
// Load entities when type changes
|
||||
@@ -222,8 +233,12 @@ const handleClose = () => {
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedEntityId.value) return
|
||||
emit('confirm', selectedEntityId.value)
|
||||
if (!selectedTypeId.value) return
|
||||
emit('confirm', {
|
||||
entityId: selectedEntityId.value || undefined,
|
||||
modelTypeId: selectedTypeId.value,
|
||||
modelTypeName: selectedTypeName.value,
|
||||
})
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -28,12 +28,15 @@
|
||||
<div v-for="component in components" :key="component.id">
|
||||
<ComponentHierarchy
|
||||
:components="[component]"
|
||||
:is-edit-mode="false"
|
||||
:is-edit-mode="isEditMode"
|
||||
:show-delete="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-component', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('remove-component', component.linkId || component.id)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -68,5 +71,6 @@ defineEmits<{
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-component': []
|
||||
'remove-component': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-piece', $event)"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('remove-piece', piece.linkId || piece.id)"
|
||||
@fill-entity="(linkId, typeId) => $emit('fill-entity', linkId, typeId)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,7 +67,9 @@ defineEmits<{
|
||||
'toggle-collapse': []
|
||||
'update-piece': [piece: any]
|
||||
'edit-piece': [piece: any]
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-piece': []
|
||||
'remove-piece': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -23,14 +23,24 @@
|
||||
<div v-if="products.length" class="space-y-3">
|
||||
<div
|
||||
v-for="product in products"
|
||||
:key="product.id || product.name"
|
||||
class="rounded border border-base-200 bg-base-200/60 p-3 text-sm space-y-2"
|
||||
:key="product.id || product.linkId || product.name"
|
||||
class="rounded border p-3 text-sm space-y-2"
|
||||
:class="product.pendingEntity ? 'border-error bg-error/10' : 'border-base-200 bg-base-200/60'"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<p class="font-semibold text-base-content">
|
||||
<p class="font-semibold" :class="product.pendingEntity ? 'text-error' : 'text-base-content'">
|
||||
{{ product.name }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="product.pendingEntity"
|
||||
type="button"
|
||||
class="badge badge-error badge-sm cursor-pointer hover:badge-outline transition-colors"
|
||||
title="Cliquer pour associer un item"
|
||||
@click="$emit('fill-entity', (product.linkId || product.id) as string, product.modelTypeId as string)"
|
||||
>
|
||||
À remplir
|
||||
</button>
|
||||
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||
{{ product.groupLabel }}
|
||||
</span>
|
||||
@@ -141,6 +151,9 @@ defineProps<{
|
||||
supplierLabel?: string | null
|
||||
priceLabel?: string | null
|
||||
groupLabel?: string
|
||||
pendingEntity?: boolean
|
||||
modelTypeId?: string | null
|
||||
modelType?: string | null
|
||||
documents?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
@@ -156,6 +169,7 @@ defineProps<{
|
||||
defineEmits<{
|
||||
'add-product': []
|
||||
'remove-product': [linkId: string]
|
||||
'fill-entity': [linkId: string, modelTypeId: string]
|
||||
}>()
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
|
||||
@@ -288,14 +288,16 @@ export function useComponentEdit(componentId: string) {
|
||||
if (!structure?.pieces) return []
|
||||
return (structure.pieces as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.pieces[slot.slotId]
|
||||
const selectedPieceId = edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null)
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typePieceId: slot.typePieceId,
|
||||
selectedPieceId: edits && 'selectedPieceId' in edits ? edits.selectedPieceId : (slot.selectedPieceId ?? null),
|
||||
selectedPieceId,
|
||||
selectedPieceName: slot.selectedPieceName ?? null,
|
||||
quantity: edits && 'quantity' in edits ? edits.quantity! : (slot.quantity ?? 1),
|
||||
position: slot.position ?? i,
|
||||
label: pieceTypeLabelMap.value[slot.typePieceId] || `Pièce #${i + 1}`,
|
||||
isEmpty: !selectedPieceId,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -305,14 +307,16 @@ export function useComponentEdit(componentId: string) {
|
||||
if (!structure?.products) return []
|
||||
return (structure.products as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.products[slot.slotId]
|
||||
const selectedProductId = edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null)
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typeProductId: slot.typeProductId,
|
||||
selectedProductId: edits && 'selectedProductId' in edits ? edits.selectedProductId : (slot.selectedProductId ?? null),
|
||||
selectedProductId,
|
||||
selectedProductName: slot.selectedProductName ?? null,
|
||||
familyCode: slot.familyCode,
|
||||
position: slot.position ?? i,
|
||||
label: productTypeLabelMap.value[slot.typeProductId] || `Produit #${i + 1}`,
|
||||
isEmpty: !selectedProductId,
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -322,15 +326,17 @@ export function useComponentEdit(componentId: string) {
|
||||
if (!structure?.subcomponents) return []
|
||||
return (structure.subcomponents as any[]).map((slot: any, i: number) => {
|
||||
const edits = slotEdits.subcomponents[slot.slotId]
|
||||
const selectedComponentId = edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null)
|
||||
return {
|
||||
slotId: slot.slotId,
|
||||
typeComposantId: slot.typeComposantId,
|
||||
selectedComponentId: edits && 'selectedComposantId' in edits ? edits.selectedComposantId : (slot.selectedComponentId ?? null),
|
||||
selectedComponentId,
|
||||
selectedComponentName: slot.selectedComponentName ?? null,
|
||||
alias: slot.alias,
|
||||
familyCode: slot.familyCode,
|
||||
position: slot.position ?? i,
|
||||
label: slot.alias || `Sous-composant #${i + 1}`,
|
||||
isEmpty: !selectedComponentId,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ export function useEntityCustomFields(deps: EntityCustomFieldsDeps) {
|
||||
definitionSources.value,
|
||||
entity().customFieldValues,
|
||||
),
|
||||
),
|
||||
).filter((field: any) => !field.machineContextOnly && !field.customField?.machineContextOnly),
|
||||
)
|
||||
|
||||
const candidateCustomFields = computed(() =>
|
||||
|
||||
@@ -44,6 +44,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const machineCustomFields = ref<AnyRecord[]>([])
|
||||
const pendingContextFieldUpdates = ref<AnyRecord[]>([])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Computed
|
||||
@@ -140,7 +141,9 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
|
||||
return {
|
||||
...normalizedPiece,
|
||||
customFields,
|
||||
customFields: customFields.filter((f: any) => !f.machineContextOnly && !f.customField?.machineContextOnly),
|
||||
contextCustomFields: piece.contextCustomFields ?? [],
|
||||
contextCustomFieldValues: piece.contextCustomFieldValues ?? [],
|
||||
documents: piece.documents || [],
|
||||
constructeurs: constructeursList,
|
||||
constructeur: constructeursList[0] || piece.constructeur || null,
|
||||
@@ -240,7 +243,9 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
|
||||
return {
|
||||
...normalizedComponent,
|
||||
customFields,
|
||||
customFields: customFields.filter((f: any) => !f.machineContextOnly && !f.customField?.machineContextOnly),
|
||||
contextCustomFields: component.contextCustomFields ?? [],
|
||||
contextCustomFieldValues: component.contextCustomFieldValues ?? [],
|
||||
pieces: piecesTransformed,
|
||||
subComponents,
|
||||
documents: component.documents || [],
|
||||
@@ -376,6 +381,83 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomFieldUpdate = async (fieldUpdate: AnyRecord) => {
|
||||
if (fieldUpdate?.entityType && fieldUpdate?.entityId) {
|
||||
queueContextFieldUpdate(fieldUpdate)
|
||||
return
|
||||
}
|
||||
|
||||
await updatePieceCustomField(fieldUpdate)
|
||||
}
|
||||
|
||||
const queueContextFieldUpdate = (fieldUpdate: AnyRecord) => {
|
||||
const entityType = fieldUpdate.entityType as string | undefined
|
||||
const entityId = fieldUpdate.entityId as string | undefined
|
||||
const fieldId = fieldUpdate.fieldId as string | undefined
|
||||
const customFieldValueId = fieldUpdate.customFieldValueId as string | undefined
|
||||
|
||||
if (!entityType || !entityId || (!fieldId && !customFieldValueId)) return
|
||||
|
||||
const nextUpdate = {
|
||||
entityType,
|
||||
entityId,
|
||||
fieldId,
|
||||
customFieldValueId,
|
||||
value: fieldUpdate.value ?? '',
|
||||
fieldName: fieldUpdate.fieldName ?? 'Champ contextuel',
|
||||
}
|
||||
|
||||
const existingIndex = pendingContextFieldUpdates.value.findIndex(
|
||||
(item) =>
|
||||
item.entityType === entityType &&
|
||||
item.entityId === entityId &&
|
||||
item.fieldId === fieldId &&
|
||||
item.customFieldValueId === customFieldValueId,
|
||||
)
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
pendingContextFieldUpdates.value[existingIndex] = nextUpdate
|
||||
return
|
||||
}
|
||||
|
||||
pendingContextFieldUpdates.value.push(nextUpdate)
|
||||
}
|
||||
|
||||
const clearPendingContextFieldUpdates = () => {
|
||||
pendingContextFieldUpdates.value = []
|
||||
}
|
||||
|
||||
const saveAllContextCustomFields = async () => {
|
||||
const updates = pendingContextFieldUpdates.value.slice()
|
||||
if (!updates.length) return
|
||||
|
||||
try {
|
||||
for (const update of updates) {
|
||||
if (update.customFieldValueId) {
|
||||
await updateCustomFieldValueApi(update.customFieldValueId as string, {
|
||||
value: update.value ?? '',
|
||||
} as any)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!update.fieldId) {
|
||||
continue
|
||||
}
|
||||
|
||||
await upsertCustomFieldValue(
|
||||
update.fieldId as string,
|
||||
update.entityType as string,
|
||||
update.entityId as string,
|
||||
update.value ?? '',
|
||||
)
|
||||
}
|
||||
clearPendingContextFieldUpdates()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la sauvegarde batch des champs contextuels:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const saveAllMachineCustomFields = async () => {
|
||||
if (!machine.value) return
|
||||
|
||||
@@ -431,6 +513,7 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
return {
|
||||
// State
|
||||
machineCustomFields,
|
||||
pendingContextFieldUpdates,
|
||||
|
||||
// Computed
|
||||
visibleMachineCustomFields,
|
||||
@@ -444,6 +527,10 @@ export function useMachineDetailCustomFields(deps: MachineDetailCustomFieldsDeps
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
handleCustomFieldUpdate,
|
||||
queueContextFieldUpdate,
|
||||
clearPendingContextFieldUpdates,
|
||||
saveAllMachineCustomFields,
|
||||
saveAllContextCustomFields,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,13 +151,18 @@ export function useMachineDetailData(machineId: string) {
|
||||
const {
|
||||
machineCustomFields,
|
||||
visibleMachineCustomFields,
|
||||
pendingContextFieldUpdates,
|
||||
transformCustomFields,
|
||||
transformComponentCustomFields,
|
||||
syncMachineCustomFields,
|
||||
setMachineCustomFieldValue,
|
||||
updateMachineCustomField,
|
||||
updatePieceCustomField,
|
||||
handleCustomFieldUpdate,
|
||||
queueContextFieldUpdate,
|
||||
clearPendingContextFieldUpdates,
|
||||
saveAllMachineCustomFields,
|
||||
saveAllContextCustomFields,
|
||||
} = useMachineDetailCustomFields({
|
||||
machine,
|
||||
isEditMode,
|
||||
@@ -193,6 +198,10 @@ export function useMachineDetailData(machineId: string) {
|
||||
removePieceLink,
|
||||
addProductLink,
|
||||
removeProductLink,
|
||||
addComponentLinkCategoryOnly,
|
||||
addPieceLinkCategoryOnly,
|
||||
addProductLinkCategoryOnly,
|
||||
fillEntityLink,
|
||||
} = hierarchy
|
||||
|
||||
// Keep the product links proxy in sync with the hierarchy's machineProductLinks
|
||||
@@ -329,10 +338,13 @@ export function useMachineDetailData(machineId: string) {
|
||||
// 2. Save all custom field values
|
||||
await saveAllMachineCustomFields()
|
||||
|
||||
// 3. Reload machine data to get fresh state
|
||||
// 3. Save contextual custom field values queued from piece/component inputs
|
||||
await saveAllContextCustomFields()
|
||||
|
||||
// 4. Reload machine data to get fresh state
|
||||
await loadMachineData()
|
||||
|
||||
// 4. Exit edit mode
|
||||
// 5. Exit edit mode
|
||||
isEditMode.value = false
|
||||
toast.showSuccess('Machine mise à jour avec succès')
|
||||
} catch (error) {
|
||||
@@ -346,6 +358,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
const cancelEdition = () => {
|
||||
initMachineFields()
|
||||
syncMachineCustomFields()
|
||||
clearPendingContextFieldUpdates()
|
||||
constructeurLinks.value = originalConstructeurLinks.value.map(l => ({ ...l }))
|
||||
machineConstructeurIds.value = constructeurIdsFromLinks(constructeurLinks.value)
|
||||
isEditMode.value = false
|
||||
@@ -482,7 +495,7 @@ export function useMachineDetailData(machineId: string) {
|
||||
|
||||
// UI state
|
||||
machineDocumentFiles, machineDocumentsUploading, machineDocumentsLoaded,
|
||||
machineCustomFields, previewDocument, previewVisible,
|
||||
machineCustomFields, pendingContextFieldUpdates, previewDocument, previewVisible,
|
||||
isEditMode, debug,
|
||||
componentsCollapsed, collapseToggleToken, piecesCollapsed, pieceCollapseToggleToken,
|
||||
|
||||
@@ -495,7 +508,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
findProductById, resolveProductReference, getProductDisplay,
|
||||
initMachineFields, getMachineFieldId,
|
||||
syncMachineCustomFields, setMachineCustomFieldValue,
|
||||
updateMachineCustomField, updatePieceCustomField,
|
||||
updateMachineCustomField, updatePieceCustomField, handleCustomFieldUpdate,
|
||||
queueContextFieldUpdate, clearPendingContextFieldUpdates, saveAllContextCustomFields,
|
||||
refreshMachineDocuments, handleMachineFilesAdded, removeMachineDocument,
|
||||
openPreview, closePreview,
|
||||
updateMachineInfo, updateComponent, updatePieceFromComponent,
|
||||
@@ -511,6 +525,8 @@ export function useMachineDetailData(machineId: string) {
|
||||
loadMachineData, loadInitialData,
|
||||
addComponentLink, removeComponentLink, addPieceLink, removePieceLink,
|
||||
addProductLink, removeProductLink, reloadMachineStructure,
|
||||
addComponentLinkCategoryOnly, addPieceLinkCategoryOnly,
|
||||
addProductLinkCategoryOnly, fillEntityLink,
|
||||
|
||||
// External
|
||||
constructeurs, loadProducts, updateMachineStructure, toast,
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
||||
syncMachineCustomFields,
|
||||
} = deps
|
||||
|
||||
const { get, post: apiPost, delete: apiDel } = useApi()
|
||||
const { get, post: apiPost, delete: apiDel, patch: apiPatch } = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -263,6 +263,69 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
||||
return result
|
||||
}
|
||||
|
||||
const addComponentLinkCategoryOnly = async (modelTypeId: string) => {
|
||||
const result: any = await apiPost('/machine_component_links', {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
modelType: `/api/model_types/${modelTypeId}`,
|
||||
})
|
||||
if (result.success) {
|
||||
toast.showSuccess('Catégorie ajoutée — item à remplir')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout')
|
||||
}
|
||||
}
|
||||
|
||||
const addPieceLinkCategoryOnly = async (modelTypeId: string) => {
|
||||
const result: any = await apiPost('/machine_piece_links', {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
modelType: `/api/model_types/${modelTypeId}`,
|
||||
})
|
||||
if (result.success) {
|
||||
toast.showSuccess('Catégorie ajoutée — item à remplir')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout')
|
||||
}
|
||||
}
|
||||
|
||||
const addProductLinkCategoryOnly = async (modelTypeId: string) => {
|
||||
const result: any = await apiPost('/machine_product_links', {
|
||||
machine: `/api/machines/${machineId}`,
|
||||
modelType: `/api/model_types/${modelTypeId}`,
|
||||
})
|
||||
if (result.success) {
|
||||
toast.showSuccess('Catégorie ajoutée — item à remplir')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'ajout')
|
||||
}
|
||||
}
|
||||
|
||||
const fillEntityLink = async (linkId: string, entityId: string, entityKind: string) => {
|
||||
let endpoint = ''
|
||||
let payload: Record<string, string> = {}
|
||||
|
||||
if (entityKind === 'component') {
|
||||
endpoint = `/machine_component_links/${linkId}`
|
||||
payload = { composant: `/api/composants/${entityId}` }
|
||||
} else if (entityKind === 'piece') {
|
||||
endpoint = `/machine_piece_links/${linkId}`
|
||||
payload = { piece: `/api/pieces/${entityId}` }
|
||||
} else {
|
||||
endpoint = `/machine_product_links/${linkId}`
|
||||
payload = { product: `/api/products/${entityId}` }
|
||||
}
|
||||
|
||||
const result: any = await apiPatch(endpoint, payload)
|
||||
if (result.success) {
|
||||
toast.showSuccess('Item associé avec succès')
|
||||
await reloadMachineStructure()
|
||||
} else {
|
||||
toast.showError('Erreur lors de l\'association')
|
||||
}
|
||||
}
|
||||
|
||||
const removeProductLink = async (linkId: string) => {
|
||||
const result: any = await apiDel(`/machine_product_links/${linkId}`)
|
||||
if (result.success) {
|
||||
@@ -301,6 +364,10 @@ export function useMachineDetailHierarchy(deps: MachineDetailHierarchyDeps) {
|
||||
addPieceLink,
|
||||
removePieceLink,
|
||||
addProductLink,
|
||||
addComponentLinkCategoryOnly,
|
||||
addPieceLinkCategoryOnly,
|
||||
addProductLinkCategoryOnly,
|
||||
fillEntityLink,
|
||||
removeProductLink,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
|
||||
return {
|
||||
id: (resolved?.id as string) || productId || null,
|
||||
linkId: (link.id as string) || (typeof link['@id'] === 'string' ? link['@id'].split('/').pop() : null) || null,
|
||||
name: (resolved?.name as string) || 'Produit inconnu',
|
||||
name: (resolved?.name as string) || (link.modelType as AnyRecord)?.name as string || 'Produit inconnu',
|
||||
reference: (resolved?.reference as string) || null,
|
||||
supplierLabel: resolvedConstructeurs.length
|
||||
? resolvedConstructeurs.map((c) => c.name).filter(Boolean).join(', ') || null
|
||||
@@ -111,6 +111,9 @@ export function useMachineDetailProducts(deps: MachineDetailProductsDeps) {
|
||||
priceLabel: resolved ? getProductPriceLabel(resolved) : null,
|
||||
groupLabel: ((resolved?.typeProduct as AnyRecord)?.name as string) || '',
|
||||
documents: productId ? (productDocumentsMap.value.get(productId) || []) : [],
|
||||
pendingEntity: (link.pendingEntity as boolean) || false,
|
||||
modelTypeId: (link.modelTypeId as string) || null,
|
||||
modelType: (link.modelType as string) || null,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -73,11 +73,11 @@ export function useMachineDetailUpdates(deps: UseMachineDetailUpdatesDeps) {
|
||||
const updateMachineInfo = async () => {
|
||||
if (!machine.value) return
|
||||
try {
|
||||
const result: any = await updateMachineApi(machine.value.id as string, {
|
||||
name: machineName.value,
|
||||
reference: machineReference.value,
|
||||
siteId: machineSiteId.value || undefined,
|
||||
} as any)
|
||||
const payload: Record<string, unknown> = {}
|
||||
if (machineName.value !== machine.value.name) payload.name = machineName.value
|
||||
if (machineReference.value !== machine.value.reference) payload.reference = machineReference.value
|
||||
if ((machineSiteId.value || undefined) !== ((machine.value.siteId as string) || (machine.value.site as any)?.id || undefined)) payload.siteId = machineSiteId.value || undefined
|
||||
const result: any = await updateMachineApi(machine.value.id as string, payload as any)
|
||||
if (result.success) {
|
||||
const machinePayload =
|
||||
result.data?.machine && typeof result.data.machine === 'object'
|
||||
|
||||
@@ -150,6 +150,30 @@ export const buildMachineHierarchyFromLinks = (
|
||||
const createPieceNode = (link: AnyRecord, parentComponentName: string | null = null): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
// Category-only link (no entity yet)
|
||||
if (link.pendingEntity || (!link.piece && !link.pieceId)) {
|
||||
const machinePieceLinkId = normalizePieceLinkId(link)
|
||||
const mt = (link.modelType || null) as AnyRecord | null
|
||||
return {
|
||||
id: machinePieceLinkId || `pending-${link.id}`,
|
||||
linkId: machinePieceLinkId,
|
||||
name: mt?.name || 'Catégorie sans item',
|
||||
reference: null,
|
||||
prix: null,
|
||||
pendingEntity: true,
|
||||
modelTypeId: link.modelTypeId || mt?.id || null,
|
||||
modelType: mt,
|
||||
pieceId: null,
|
||||
constructeurs: [],
|
||||
documents: [],
|
||||
customFields: [],
|
||||
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
|
||||
parentComponentName,
|
||||
machinePieceLinkId,
|
||||
quantity: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const appliedPiece = (link.piece && typeof link.piece === 'object' ? link.piece : {}) as AnyRecord
|
||||
const originalPiece = (link.originalPiece && typeof link.originalPiece === 'object' ? link.originalPiece : null) as AnyRecord | null
|
||||
|
||||
@@ -184,6 +208,8 @@ export const buildMachineHierarchyFromLinks = (
|
||||
quantity: typeof link.quantity === 'number' ? link.quantity : 1,
|
||||
definition: appliedPiece.definition || originalPiece?.definition || {},
|
||||
customFields: appliedPiece.customFields || [],
|
||||
contextCustomFields: link.contextCustomFields || [],
|
||||
contextCustomFieldValues: link.contextCustomFieldValues || [],
|
||||
}
|
||||
|
||||
const resolvedProductId = resolveIdentifier(appliedPiece.productId, (appliedPiece.product as AnyRecord)?.id, link.productId, (link.product as AnyRecord)?.id, originalPiece?.productId, (originalPiece?.product as AnyRecord)?.id)
|
||||
@@ -205,6 +231,35 @@ export const buildMachineHierarchyFromLinks = (
|
||||
const createComponentNode = (link: AnyRecord): AnyRecord | null => {
|
||||
if (!link || typeof link !== 'object') return null
|
||||
|
||||
// Category-only link (no entity yet)
|
||||
if (link.pendingEntity || (!link.composant && !link.composantId)) {
|
||||
const machineComponentLinkId = normalizeComponentLinkId(link)
|
||||
const mt = (link.modelType || null) as AnyRecord | null
|
||||
return {
|
||||
id: machineComponentLinkId || `pending-${link.id}`,
|
||||
linkId: machineComponentLinkId,
|
||||
name: mt?.name || 'Catégorie sans item',
|
||||
reference: null,
|
||||
prix: null,
|
||||
pendingEntity: true,
|
||||
modelTypeId: link.modelTypeId || mt?.id || null,
|
||||
modelType: mt,
|
||||
composantId: null,
|
||||
composant: null,
|
||||
constructeurs: [],
|
||||
documents: [],
|
||||
customFields: [],
|
||||
customFieldValues: [],
|
||||
subComponents: [],
|
||||
pieces: [],
|
||||
overrides: null,
|
||||
parentComponentLinkId: link.parentComponentLinkId || link.parentLinkId || null,
|
||||
machineComponentLinkId,
|
||||
childLinks: [],
|
||||
pieceLinks: [],
|
||||
}
|
||||
}
|
||||
|
||||
const appliedComponent = (link.composant && typeof link.composant === 'object' ? link.composant : {}) as AnyRecord
|
||||
const originalComponent = (link.originalComposant && typeof link.originalComposant === 'object' ? link.originalComposant : null) as AnyRecord | null
|
||||
|
||||
@@ -227,11 +282,13 @@ export const buildMachineHierarchyFromLinks = (
|
||||
const definition = (def.definition && typeof def.definition === 'object' ? def.definition : def) as AnyRecord
|
||||
const resolved = (def.resolvedPiece && typeof def.resolvedPiece === 'object' ? def.resolvedPiece : null) as AnyRecord | null
|
||||
const quantity = typeof definition.quantity === 'number' ? definition.quantity : (typeof def.quantity === 'number' ? def.quantity : 1)
|
||||
const isEmpty = !resolved
|
||||
const typePieceName = (resolved?.typePiece as AnyRecord)?.name || (definition.typePiece as AnyRecord)?.name || (def.typePiece as AnyRecord)?.name || null
|
||||
return {
|
||||
...(resolved || {}),
|
||||
id: resolved?.id || `structure-piece-${composantId}-${index}`,
|
||||
pieceId: resolved?.id || null,
|
||||
name: resolved?.name || definition.role || definition.name || def.role || def.name || `Pièce ${index + 1}`,
|
||||
name: resolved?.name || definition.role || definition.name || def.role || def.name || (typePieceName ? `${typePieceName}` : `Pièce ${index + 1}`),
|
||||
reference: resolved?.reference || definition.reference || def.reference || null,
|
||||
prix: resolved?.prix ?? null,
|
||||
constructeurs: resolved?.constructeurs || [],
|
||||
@@ -243,6 +300,7 @@ export const buildMachineHierarchyFromLinks = (
|
||||
parentComponentLinkId: machineComponentLinkId,
|
||||
parentComponentName: componentName,
|
||||
_structurePiece: true,
|
||||
_emptySlot: isEmpty,
|
||||
}
|
||||
}) as AnyRecord[]
|
||||
|
||||
@@ -279,6 +337,8 @@ export const buildMachineHierarchyFromLinks = (
|
||||
parentComposantId: resolveIdentifier(appliedComponent.parentComposantId, link.parentComponentId),
|
||||
definition: appliedComponent.definition || originalComponent?.definition || {},
|
||||
customFields: appliedComponent.customFields || [],
|
||||
contextCustomFields: link.contextCustomFields || [],
|
||||
contextCustomFieldValues: link.contextCustomFieldValues || [],
|
||||
pieces,
|
||||
subComponents,
|
||||
subcomponents: subComponents,
|
||||
|
||||
30
frontend/app/composables/useMaintenance.ts
Normal file
30
frontend/app/composables/useMaintenance.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from './useApi'
|
||||
|
||||
const maintenanceEnabled = ref(false)
|
||||
|
||||
export function useMaintenance() {
|
||||
const { apiCall } = useApi()
|
||||
const loading = ref(false)
|
||||
|
||||
const fetchStatus = async () => {
|
||||
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance')
|
||||
if (res.success && res.data) {
|
||||
maintenanceEnabled.value = res.data.enabled
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiCall<{ enabled: boolean }>('/admin/maintenance', { method: 'PUT' })
|
||||
if (res.success && res.data) {
|
||||
maintenanceEnabled.value = res.data.enabled
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { maintenanceEnabled, loading, fetchStatus, toggle }
|
||||
}
|
||||
@@ -88,6 +88,7 @@ const toEditorField = (
|
||||
...(typeof input?.id === 'string' && input.id ? { id: input.id } : {}),
|
||||
...(typeof input?.customFieldId === 'string' && input.customFieldId ? { customFieldId: input.customFieldId } : {}),
|
||||
orderIndex: typeof input?.orderIndex === 'number' ? input.orderIndex : index,
|
||||
machineContextOnly: Boolean(input?.machineContextOnly),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +163,7 @@ const buildPayload = (
|
||||
type,
|
||||
required,
|
||||
orderIndex: index,
|
||||
machineContextOnly: Boolean(field.machineContextOnly),
|
||||
}
|
||||
|
||||
if (field.id) {
|
||||
@@ -286,6 +288,7 @@ export function usePieceStructureEditorLogic(deps: Deps) {
|
||||
type: 'text',
|
||||
required: false,
|
||||
optionsText: '',
|
||||
machineContextOnly: false,
|
||||
orderIndex,
|
||||
})
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export function useStructureNodeCrud(props: StructureNodeCrudDeps) {
|
||||
required: false,
|
||||
optionsText: '',
|
||||
options: [],
|
||||
machineContextOnly: false,
|
||||
orderIndex: nextIndex,
|
||||
})
|
||||
reindexCustomFields()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useProfileSession, usePermissions } from "#imports";
|
||||
import { useProfileSession, usePermissions, useApi } from "#imports";
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { ensureSession, activeProfile } = useProfileSession();
|
||||
@@ -12,9 +12,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
normalizedPath.startsWith("/profiles") ||
|
||||
fullPath.startsWith("/profiles") ||
|
||||
routeName.startsWith("profiles");
|
||||
const isMaintenanceRoute = normalizedPath === "/maintenance";
|
||||
|
||||
// Redirect to login if no active profile
|
||||
if (!activeProfile.value && !isProfilesRoute) {
|
||||
if (!activeProfile.value && !isProfilesRoute && !isMaintenanceRoute) {
|
||||
return navigateTo("/profiles");
|
||||
}
|
||||
|
||||
@@ -29,5 +30,13 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Maintenance mode check for non-admin users
|
||||
if (!isAdmin.value && !isMaintenanceRoute && !isProfilesRoute) {
|
||||
const { apiCall } = useApi();
|
||||
const res = await apiCall<{ enabled: boolean }>('/maintenance/check');
|
||||
if (res.success && res.data?.enabled) {
|
||||
return navigateTo("/maintenance");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
<template>
|
||||
<div class="container mx-auto p-6 max-w-6xl">
|
||||
<!-- Maintenance Mode -->
|
||||
<div class="alert mb-6" :class="maintenanceEnabled ? 'alert-warning' : 'alert-info'">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Mode maintenance</span>
|
||||
<span v-if="maintenanceEnabled" class="badge badge-warning badge-sm">Actif</span>
|
||||
<span v-else class="badge badge-ghost badge-sm">Inactif</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="maintenanceEnabled ? 'btn-ghost' : 'btn-warning'"
|
||||
:disabled="maintenanceLoading"
|
||||
@click="handleToggleMaintenance"
|
||||
>
|
||||
<span v-if="maintenanceLoading" class="loading loading-spinner loading-xs" />
|
||||
{{ maintenanceEnabled ? 'Désactiver' : 'Activer' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm opacity-70 mt-1">
|
||||
{{ maintenanceEnabled ? 'Seuls les administrateurs peuvent accéder à l\'application.' : 'L\'application est accessible à tous les utilisateurs.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold">
|
||||
Administration des profils
|
||||
@@ -153,9 +176,14 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import { useAdminProfiles } from '#imports'
|
||||
import { useAdminProfiles, useMaintenance } from '#imports'
|
||||
|
||||
const { profiles, loading, fetchAll, createProfile, updateRole, setPassword, deactivateProfile } = useAdminProfiles()
|
||||
const { maintenanceEnabled, loading: maintenanceLoading, fetchStatus: fetchMaintenanceStatus, toggle: toggleMaintenance } = useMaintenance()
|
||||
|
||||
const handleToggleMaintenance = async () => {
|
||||
await toggleMaintenance()
|
||||
}
|
||||
|
||||
const loaded = ref(false)
|
||||
const isLoading = computed(() => loading.value || !loaded.value)
|
||||
@@ -264,7 +292,7 @@ const handleDeactivate = async (profileId) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
await Promise.all([fetchAll(), fetchMaintenanceStatus()])
|
||||
loaded.value = true
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||
</label>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
@@ -231,7 +231,7 @@
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||
</label>
|
||||
<ProductSelect
|
||||
:model-value="slot.selectedProductId"
|
||||
@@ -252,7 +252,7 @@
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||
</label>
|
||||
<ComposantSelect
|
||||
:model-value="slot.selectedComponentId"
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<div class="flex items-start gap-2">
|
||||
@@ -255,9 +255,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
|
||||
{{ slot.selectedPieceName || '— Non sélectionné' }}
|
||||
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
|
||||
<div v-else class="input input-bordered input-sm md:input-md flex items-center gap-2" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
|
||||
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
||||
<template v-else>
|
||||
{{ slot.selectedPieceName }}
|
||||
<span v-if="slot.quantity > 1" class="badge badge-sm">x{{ slot.quantity }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,7 +275,7 @@
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<ProductSelect
|
||||
@@ -282,8 +285,9 @@
|
||||
@update:model-value="(value) => setProductSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ slot.selectedProductName || '— Non sélectionné' }}
|
||||
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
|
||||
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
||||
<template v-else>{{ slot.selectedProductName }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,7 +302,7 @@
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ slot.label }}</span>
|
||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': slot.isEmpty }">{{ slot.label }}</span>
|
||||
</label>
|
||||
<template v-if="isEditMode">
|
||||
<ComposantSelect
|
||||
@@ -308,8 +312,9 @@
|
||||
@update:model-value="(value) => setSubcomponentSlotSelection(slot.slotId, value)"
|
||||
/>
|
||||
</template>
|
||||
<div v-else class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ slot.selectedComponentName || '— Non sélectionné' }}
|
||||
<div v-else class="input input-bordered input-sm md:input-md flex items-center" :class="slot.isEmpty ? 'border-error bg-error/10 text-error font-semibold' : 'bg-base-200'">
|
||||
<template v-if="slot.isEmpty">{{ slot.label }} — manquant</template>
|
||||
<template v-else>{{ slot.selectedComponentName }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
:is-edit-mode="d.isEditMode.value"
|
||||
@add-product="openAddModal('product')"
|
||||
@remove-product="async (id) => { await d.removeProductLink(id); refreshVersions() }"
|
||||
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'product', typeId)"
|
||||
/>
|
||||
|
||||
<!-- Components Section -->
|
||||
@@ -112,9 +113,10 @@
|
||||
@toggle-collapse="d.toggleAllComponents"
|
||||
@update-component="d.updateComponent"
|
||||
@edit-piece="d.updatePieceFromComponent"
|
||||
@custom-field-update="d.updatePieceCustomField"
|
||||
@custom-field-update="d.handleCustomFieldUpdate"
|
||||
@add-component="openAddModal('component')"
|
||||
@remove-component="async (id) => { await d.removeComponentLink(id); refreshVersions() }"
|
||||
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'component', typeId)"
|
||||
/>
|
||||
|
||||
<!-- Machine Pieces Section -->
|
||||
@@ -126,9 +128,10 @@
|
||||
:collapse-toggle-token="d.pieceCollapseToggleToken.value"
|
||||
@update-piece="d.updatePieceInfo"
|
||||
@edit-piece="d.editPiece"
|
||||
@custom-field-update="d.updatePieceCustomField"
|
||||
@custom-field-update="d.handleCustomFieldUpdate"
|
||||
@add-piece="openAddModal('piece')"
|
||||
@remove-piece="async (id) => { await d.removePieceLink(id); refreshVersions() }"
|
||||
@fill-entity="(linkId, typeId) => handleFillEntity(linkId, 'piece', typeId)"
|
||||
@toggle-collapse="d.toggleAllPieces"
|
||||
/>
|
||||
|
||||
@@ -136,7 +139,8 @@
|
||||
<AddEntityToMachineModal
|
||||
:open="addModalOpen"
|
||||
:entity-kind="addModalKind"
|
||||
@close="addModalOpen = false"
|
||||
:prefill-type-id="fillTypeId"
|
||||
@close="addModalOpen = false; fillLinkId = ''; fillTypeId = ''"
|
||||
@confirm="handleAddEntity"
|
||||
/>
|
||||
|
||||
@@ -277,6 +281,8 @@ const historyFieldLabels = {
|
||||
|
||||
const addModalOpen = ref(false)
|
||||
const addModalKind = ref('component')
|
||||
const fillLinkId = ref('')
|
||||
const fillTypeId = ref('')
|
||||
|
||||
const openAddModal = (kind) => {
|
||||
addModalKind.value = kind
|
||||
@@ -288,17 +294,40 @@ const handleRemoveConstructeurLink = (constructeurId) => {
|
||||
d.handleMachineConstructeurChange(ids)
|
||||
}
|
||||
|
||||
const handleAddEntity = async (entityId) => {
|
||||
if (addModalKind.value === 'component') {
|
||||
await d.addComponentLink(entityId)
|
||||
} else if (addModalKind.value === 'piece') {
|
||||
await d.addPieceLink(entityId)
|
||||
const handleAddEntity = async (payload) => {
|
||||
const { entityId, modelTypeId } = payload
|
||||
|
||||
if (fillLinkId.value) {
|
||||
await d.fillEntityLink(fillLinkId.value, entityId, addModalKind.value)
|
||||
fillLinkId.value = ''
|
||||
fillTypeId.value = ''
|
||||
} else if (entityId) {
|
||||
if (addModalKind.value === 'component') {
|
||||
await d.addComponentLink(entityId)
|
||||
} else if (addModalKind.value === 'piece') {
|
||||
await d.addPieceLink(entityId)
|
||||
} else {
|
||||
await d.addProductLink(entityId)
|
||||
}
|
||||
} else {
|
||||
await d.addProductLink(entityId)
|
||||
if (addModalKind.value === 'component') {
|
||||
await d.addComponentLinkCategoryOnly(modelTypeId)
|
||||
} else if (addModalKind.value === 'piece') {
|
||||
await d.addPieceLinkCategoryOnly(modelTypeId)
|
||||
} else {
|
||||
await d.addProductLinkCategoryOnly(modelTypeId)
|
||||
}
|
||||
}
|
||||
refreshVersions()
|
||||
}
|
||||
|
||||
const handleFillEntity = (linkId, entityKind, modelTypeId) => {
|
||||
fillLinkId.value = linkId
|
||||
fillTypeId.value = modelTypeId
|
||||
addModalKind.value = entityKind
|
||||
addModalOpen.value = true
|
||||
}
|
||||
|
||||
const machineViewTitle = computed(() => {
|
||||
return d.isEditMode.value ? 'Modification de la machine' : 'Détails de la machine'
|
||||
})
|
||||
|
||||
21
frontend/app/pages/maintenance.vue
Normal file
21
frontend/app/pages/maintenance.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div class="text-center max-w-md">
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
Maintenance
|
||||
</h1>
|
||||
<p class="text-lg text-base-content/70 mb-6">
|
||||
L'application est actuellement en maintenance. Veuillez réessayer ultérieurement.
|
||||
</p>
|
||||
<button class="btn btn-primary" @click="retry">
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const retry = () => {
|
||||
navigateTo('/')
|
||||
}
|
||||
</script>
|
||||
@@ -224,7 +224,7 @@
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">
|
||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelections[entry.index] }">
|
||||
{{ entry.label }}
|
||||
</span>
|
||||
</label>
|
||||
@@ -244,10 +244,11 @@
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">{{ entry.label }}</span>
|
||||
<span class="label-text text-xs font-medium" :class="{ 'text-error font-semibold': !productSelectionLabels[index] }">{{ entry.label }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center">
|
||||
{{ productSelectionLabels[index] || '— Non sélectionné' }}
|
||||
<div class="input input-bordered input-sm md:input-md flex items-center" :class="productSelectionLabels[index] ? 'bg-base-200' : 'border-error bg-error/10 text-error font-semibold'">
|
||||
<template v-if="!productSelectionLabels[index]">{{ entry.label }} — manquant</template>
|
||||
<template v-else>{{ productSelectionLabels[index] }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,6 +98,7 @@ export const normalizeStructureForEditor = (input: any): ComponentModelStructure
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
required: field.required,
|
||||
machineContextOnly: !!field.machineContextOnly,
|
||||
options,
|
||||
defaultValue,
|
||||
optionsText,
|
||||
@@ -141,6 +142,7 @@ export const normalizeStructureForSave = (input: any): any => {
|
||||
const value: Record<string, any> = {
|
||||
type: field.type,
|
||||
required: !!field.required,
|
||||
machineContextOnly: !!field.machineContextOnly,
|
||||
}
|
||||
if (field.options && field.options.length) {
|
||||
value.options = field.options
|
||||
|
||||
@@ -78,10 +78,18 @@ export const hydrateCustomFields = (fields: any[]): any[] => {
|
||||
const customFieldId = typeof field?.customFieldId === 'string' ? field.customFieldId : undefined
|
||||
const orderIndex = typeof field?.orderIndex === 'number' ? field.orderIndex : index
|
||||
|
||||
const machineContextOnly =
|
||||
typeof field?.machineContextOnly === 'boolean'
|
||||
? field.machineContextOnly
|
||||
: typeof valueObject?.machineContextOnly === 'boolean'
|
||||
? valueObject.machineContextOnly
|
||||
: false
|
||||
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
machineContextOnly,
|
||||
options,
|
||||
optionsText,
|
||||
defaultValue,
|
||||
@@ -153,6 +161,7 @@ export const mapComponentCustomFields = (fields: any[]) => {
|
||||
name: typeof field?.name === 'string' ? field.name : '',
|
||||
type: field?.type ?? 'text',
|
||||
required: !!field?.required,
|
||||
machineContextOnly: !!field?.machineContextOnly,
|
||||
options: Array.isArray(field?.options) ? field.options : [],
|
||||
optionsText: typeof field?.optionsText === 'string' ? field.optionsText : '',
|
||||
defaultValue,
|
||||
|
||||
@@ -85,7 +85,10 @@ export const sanitizeCustomFields = (fields: any[]): ComponentModelCustomField[]
|
||||
}
|
||||
}
|
||||
|
||||
const result: ComponentModelCustomField = { name, type, required }
|
||||
const machineContextOnly =
|
||||
typeof valueObject?.machineContextOnly === 'boolean' ? valueObject.machineContextOnly : !!field?.machineContextOnly
|
||||
|
||||
const result: ComponentModelCustomField = { name, type, required, machineContextOnly }
|
||||
if (options) {
|
||||
result.options = options
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ const sanitizePieceCustomFields = (fields: any[]): PieceModelCustomField[] => {
|
||||
options = parsed.length > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
const result: PieceModelCustomField = { name, type, required }
|
||||
const result: PieceModelCustomField = { name, type, required, machineContextOnly: !!field?.machineContextOnly }
|
||||
if (options) {
|
||||
result.options = options
|
||||
}
|
||||
@@ -131,6 +131,7 @@ const hydratePieceCustomFields = (fields: any[]): PieceModelStructureEditorField
|
||||
name: field?.name ?? '',
|
||||
type: field?.type ?? 'text',
|
||||
required: !!field?.required,
|
||||
machineContextOnly: !!field?.machineContextOnly,
|
||||
options: Array.isArray(field?.options) ? field.options : undefined,
|
||||
optionsText: typeof field?.optionsText === 'string'
|
||||
? field.optionsText
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ComponentModelCustomField {
|
||||
id?: string
|
||||
customFieldId?: string
|
||||
orderIndex?: number
|
||||
machineContextOnly?: boolean
|
||||
key?: string
|
||||
value?: unknown
|
||||
}
|
||||
@@ -58,6 +59,7 @@ export interface PieceModelCustomField {
|
||||
required: boolean
|
||||
options?: string[]
|
||||
orderIndex?: number
|
||||
machineContextOnly?: boolean
|
||||
key?: string
|
||||
value?: unknown
|
||||
defaultValue?: string | null
|
||||
|
||||
@@ -3,12 +3,14 @@ import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Lire la version depuis le fichier VERSION à la racine du projet parent
|
||||
// Lire la version depuis config/version.yaml à la racine du projet parent
|
||||
const getAppVersion = (): string => {
|
||||
try {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const versionPath = resolve(__dirname, '..', 'VERSION')
|
||||
return readFileSync(versionPath, 'utf-8').trim()
|
||||
const versionPath = resolve(__dirname, '..', 'config', 'version.yaml')
|
||||
const content = readFileSync(versionPath, 'utf-8')
|
||||
const match = content.match(/app\.version:\s*'([^']+)'/)
|
||||
return match ? match[1] : '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ COPY migrations migrations/
|
||||
COPY public public/
|
||||
COPY src src/
|
||||
COPY templates templates/
|
||||
COPY VERSION VERSION
|
||||
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
@@ -31,6 +30,7 @@ COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
COPY config/version.yaml /app/config/version.yaml
|
||||
ENV CI=1 \
|
||||
NUXT_TELEMETRY_DISABLED=1 \
|
||||
NUXT_PUBLIC_API_BASE_URL=/api \
|
||||
@@ -42,7 +42,7 @@ FROM php:8.4-fpm AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
nginx supervisor \
|
||||
nginx supervisor qpdf \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -61,8 +61,9 @@ RUN ln -sf /dev/stdout /var/log/nginx/access.log \
|
||||
RUN rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Configs
|
||||
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/inventory.conf
|
||||
COPY infra/prod/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY infra/prod/nginx.conf /etc/nginx/sites-enabled/inventory.conf
|
||||
COPY infra/prod/maintenance.html /var/www/html/public/maintenance.html
|
||||
|
||||
# Backend from stage 1
|
||||
COPY --from=backend-build /app /var/www/html
|
||||
@@ -74,7 +75,7 @@ COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
# Permissions
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads \
|
||||
RUN mkdir -p /var/www/html/var /var/www/html/var/uploads /var/www/html/var/storage/documents \
|
||||
&& chown -R www-data:www-data /var/www/html/var
|
||||
|
||||
WORKDIR /var/www/html
|
||||
51
infra/prod/deploy.sh
Executable file
51
infra/prod/deploy.sh
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export INVENTORY_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying inventory:${TAG}..."
|
||||
|
||||
# Fix storage directory structure (one-time migration fix)
|
||||
# Files were nested under storage/storage/documents/ instead of storage/ directly
|
||||
if [ -d "storage/storage/documents" ]; then
|
||||
echo "==> Fixing storage directory structure..."
|
||||
cp -a storage/storage/documents/* storage/ 2>/dev/null || true
|
||||
rm -rf storage/storage
|
||||
echo "==> Storage structure fixed."
|
||||
fi
|
||||
|
||||
# Ensure storage directory exists with correct ownership
|
||||
mkdir -p storage
|
||||
sudo chown -R www-data:www-data storage
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
sudo docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
sudo docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Extracting maintenance page..."
|
||||
mkdir -p public
|
||||
sudo docker compose cp app:/var/www/html/public/maintenance.html public/maintenance.html
|
||||
|
||||
echo "==> Running migrations..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
sudo docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(sudo docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
@@ -6,7 +6,7 @@ services:
|
||||
ports:
|
||||
- "8082:80"
|
||||
volumes:
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
- ./storage:/var/www/html/var/storage/documents
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
49
infra/prod/maintenance.html
Normal file
49
infra/prod/maintenance.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Maintenance en cours</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.10);
|
||||
padding: 48px 40px;
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
color: #111827;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">🛠</div>
|
||||
<h1>Maintenance en cours</h1>
|
||||
<p>L'application est temporairement indisponible pour mise à jour. Elle sera de retour dans quelques instants.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
30
infra/prod/nginx-proxy.conf
Normal file
30
infra/prod/nginx-proxy.conf
Normal file
@@ -0,0 +1,30 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name inventory.malio-dev.fr;
|
||||
|
||||
root /var/www/inventory/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/inventory/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8082;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,23 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/html/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
root /var/www/html/public;
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
root /var/www/html/public;
|
||||
internal;
|
||||
}
|
||||
|
||||
root /var/www/html/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
20
makefile
20
makefile
@@ -1,6 +1,6 @@
|
||||
# Permet d'utiliser un .env.docker.local pour override
|
||||
ENV_DEFAULT = docker/.env.docker
|
||||
ENV_LOCAL = docker/.env.docker.local
|
||||
ENV_DEFAULT = infra/dev/.env.docker
|
||||
ENV_LOCAL = infra/dev/.env.docker.local
|
||||
ENV_FILE := $(if $(wildcard $(ENV_LOCAL)),$(ENV_LOCAL),$(ENV_DEFAULT))
|
||||
|
||||
# Permet d'avoir les variables du fichier .env.docker.local
|
||||
@@ -25,13 +25,13 @@ DATA_SQL_NORM ?= data_norm.sql
|
||||
#========================================================================================
|
||||
|
||||
env-init:
|
||||
@mkdir -p docker
|
||||
@mkdir -p infra/dev
|
||||
@cp --update=none $(ENV_DEFAULT) $(ENV_LOCAL)
|
||||
|
||||
# Lance le container
|
||||
start: env-init
|
||||
@echo "**** START CONTAINERS ****"
|
||||
@cp --update=none docker/.env.docker docker/.env.docker.local
|
||||
@cp --update=none infra/dev/.env.docker infra/dev/.env.docker.local
|
||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||
@echo ""
|
||||
@echo "URLs disponibles:"
|
||||
@@ -47,7 +47,7 @@ restart: env-init
|
||||
$(DOCKER_COMPOSE) down
|
||||
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
|
||||
|
||||
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS
|
||||
install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate
|
||||
|
||||
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
|
||||
reset: delete_built_dir remove_orphans build-without-cache start wait install
|
||||
@@ -89,6 +89,9 @@ db-restart:
|
||||
$(DOCKER_COMPOSE) down
|
||||
$(DOCKER_COMPOSE) up -d
|
||||
|
||||
migration-migrate:
|
||||
$(SYMFONY_CONSOLE) doctrine:migrations:migrate --no-interaction
|
||||
|
||||
cache-clear:
|
||||
$(SYMFONY_CONSOLE) cache:clear
|
||||
|
||||
@@ -105,6 +108,13 @@ copy-git-hook:
|
||||
shell:
|
||||
$(EXEC_PHP_INTERACTIVE) bash
|
||||
|
||||
shell-root:
|
||||
$(EXEC_PHP_INTERACTIVE_ROOT) bash
|
||||
|
||||
# Suivi temps réel des logs dev
|
||||
logs-dev:
|
||||
$(EXEC_PHP_INTERACTIVE) sh -lc "tail -f var/log/dev.log"
|
||||
|
||||
# Force la version node
|
||||
node-use:
|
||||
bash -lc 'source "$$HOME/.nvm/nvm.sh" && nvm install && nvm use'
|
||||
|
||||
56
migrations/Version20260403084805.php
Normal file
56
migrations/Version20260403084805.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260403084805 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add machineContextOnly to custom_fields + link FKs on custom_field_values';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE custom_fields ADD COLUMN IF NOT EXISTS machinecontextonly BOOLEAN DEFAULT false NOT NULL');
|
||||
|
||||
$this->addSql('ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinecomponentlinkid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE custom_field_values ADD COLUMN IF NOT EXISTS machinepiecelinkid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_component_link') THEN
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_component_link
|
||||
FOREIGN KEY (machinecomponentlinkid) REFERENCES machine_component_links(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_cfv_machine_piece_link') THEN
|
||||
ALTER TABLE custom_field_values ADD CONSTRAINT fk_cfv_machine_piece_link
|
||||
FOREIGN KEY (machinepiecelinkid) REFERENCES machine_piece_links(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_cfv_machine_component_link ON custom_field_values(machinecomponentlinkid)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_cfv_machine_piece_link ON custom_field_values(machinepiecelinkid)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_machine_component_link');
|
||||
$this->addSql('ALTER TABLE custom_field_values DROP CONSTRAINT IF EXISTS fk_cfv_machine_piece_link');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_cfv_machine_component_link');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_cfv_machine_piece_link');
|
||||
$this->addSql('ALTER TABLE custom_field_values DROP COLUMN IF EXISTS machinecomponentlinkid');
|
||||
$this->addSql('ALTER TABLE custom_field_values DROP COLUMN IF EXISTS machinepiecelinkid');
|
||||
$this->addSql('ALTER TABLE custom_fields DROP COLUMN IF EXISTS machinecontextonly');
|
||||
}
|
||||
}
|
||||
94
migrations/Version20260403_CategoryOnlyLinks.php
Normal file
94
migrations/Version20260403_CategoryOnlyLinks.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260403_CategoryOnlyLinks extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Allow category-only machine links: make entity FKs nullable, add modelType FK to link tables';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// 1. Make entity FK columns nullable
|
||||
$this->addSql('ALTER TABLE machine_component_links ALTER COLUMN composantid DROP NOT NULL');
|
||||
$this->addSql('ALTER TABLE machine_piece_links ALTER COLUMN pieceid DROP NOT NULL');
|
||||
$this->addSql('ALTER TABLE machine_product_links ALTER COLUMN productid DROP NOT NULL');
|
||||
|
||||
// 2. Add modeltypeid column to all 3 tables
|
||||
$this->addSql('ALTER TABLE machine_component_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE machine_piece_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE machine_product_links ADD COLUMN IF NOT EXISTS modeltypeid VARCHAR(36) DEFAULT NULL');
|
||||
|
||||
// 3. Add FK constraints from modeltypeid to model_types(id) ON DELETE SET NULL
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_machine_component_links_modeltype' AND table_name = 'machine_component_links'
|
||||
) THEN
|
||||
ALTER TABLE machine_component_links ADD CONSTRAINT fk_machine_component_links_modeltype
|
||||
FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_machine_piece_links_modeltype' AND table_name = 'machine_piece_links'
|
||||
) THEN
|
||||
ALTER TABLE machine_piece_links ADD CONSTRAINT fk_machine_piece_links_modeltype
|
||||
FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
$this->addSql(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE constraint_name = 'fk_machine_product_links_modeltype' AND table_name = 'machine_product_links'
|
||||
) THEN
|
||||
ALTER TABLE machine_product_links ADD CONSTRAINT fk_machine_product_links_modeltype
|
||||
FOREIGN KEY (modeltypeid) REFERENCES model_types(id) ON DELETE SET NULL;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
|
||||
// 4. Add indexes on modeltypeid
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_component_links_modeltypeid ON machine_component_links (modeltypeid)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_piece_links_modeltypeid ON machine_piece_links (modeltypeid)');
|
||||
$this->addSql('CREATE INDEX IF NOT EXISTS idx_machine_product_links_modeltypeid ON machine_product_links (modeltypeid)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Drop indexes
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_machine_component_links_modeltypeid');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_machine_piece_links_modeltypeid');
|
||||
$this->addSql('DROP INDEX IF EXISTS idx_machine_product_links_modeltypeid');
|
||||
|
||||
// Drop FK constraints
|
||||
$this->addSql('ALTER TABLE machine_component_links DROP CONSTRAINT IF EXISTS fk_machine_component_links_modeltype');
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP CONSTRAINT IF EXISTS fk_machine_piece_links_modeltype');
|
||||
$this->addSql('ALTER TABLE machine_product_links DROP CONSTRAINT IF EXISTS fk_machine_product_links_modeltype');
|
||||
|
||||
// Drop modeltypeid columns
|
||||
$this->addSql('ALTER TABLE machine_component_links DROP COLUMN IF EXISTS modeltypeid');
|
||||
$this->addSql('ALTER TABLE machine_piece_links DROP COLUMN IF EXISTS modeltypeid');
|
||||
$this->addSql('ALTER TABLE machine_product_links DROP COLUMN IF EXISTS modeltypeid');
|
||||
|
||||
// Restore NOT NULL on entity FK columns
|
||||
$this->addSql('ALTER TABLE machine_component_links ALTER COLUMN composantid SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE machine_piece_links ALTER COLUMN pieceid SET NOT NULL');
|
||||
$this->addSql('ALTER TABLE machine_product_links ALTER COLUMN productid SET NOT NULL');
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@ NC='\033[0m' # No Color
|
||||
# Répertoire racine du projet
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
VERSION_FILE="$PROJECT_ROOT/VERSION"
|
||||
VERSION_FILE="$PROJECT_ROOT/config/version.yaml"
|
||||
API_PLATFORM_FILE="$PROJECT_ROOT/config/packages/api_platform.yaml"
|
||||
|
||||
# Lire la version actuelle
|
||||
current_version=$(cat "$VERSION_FILE" | tr -d '\n')
|
||||
current_version=$(awk -F': *' '/app\.version:/{print $2}' "$VERSION_FILE" | tr -d "' \n\r")
|
||||
|
||||
# Fonction pour afficher l'aide
|
||||
show_help() {
|
||||
@@ -113,8 +113,8 @@ cd "$PROJECT_ROOT"
|
||||
# ===========================================
|
||||
# ÉTAPE 1 : Mettre à jour VERSION
|
||||
# ===========================================
|
||||
echo -e "${BLUE}[1/4]${NC} Mise à jour du fichier VERSION..."
|
||||
echo "$new_version" > "$VERSION_FILE"
|
||||
echo -e "${BLUE}[1/4]${NC} Mise à jour de config/version.yaml..."
|
||||
printf "parameters:\\n app.version: '%s'\\n" "$new_version" > "$VERSION_FILE"
|
||||
|
||||
# ===========================================
|
||||
# ÉTAPE 2 : Mettre à jour api_platform.yaml
|
||||
|
||||
@@ -9,6 +9,8 @@ use App\Entity\CustomFieldValue;
|
||||
use App\Repository\ComposantRepository;
|
||||
use App\Repository\CustomFieldRepository;
|
||||
use App\Repository\CustomFieldValueRepository;
|
||||
use App\Repository\MachineComponentLinkRepository;
|
||||
use App\Repository\MachinePieceLinkRepository;
|
||||
use App\Repository\MachineRepository;
|
||||
use App\Repository\PieceRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
@@ -29,6 +31,8 @@ class CustomFieldValueController extends AbstractController
|
||||
private readonly ComposantRepository $composantRepository,
|
||||
private readonly PieceRepository $pieceRepository,
|
||||
private readonly ProductRepository $productRepository,
|
||||
private readonly MachineComponentLinkRepository $machineComponentLinkRepository,
|
||||
private readonly MachinePieceLinkRepository $machinePieceLinkRepository,
|
||||
) {}
|
||||
|
||||
#[Route('', name: 'custom_field_values_create', methods: ['POST'])]
|
||||
@@ -214,7 +218,7 @@ class CustomFieldValueController extends AbstractController
|
||||
$entityId = isset($payload['entityId']) ? trim((string) $payload['entityId']) : '';
|
||||
|
||||
if ('' === $entityType || '' === $entityId) {
|
||||
foreach (['machine', 'composant', 'piece', 'product'] as $candidate) {
|
||||
foreach (['machine', 'composant', 'piece', 'product', 'machineComponentLink', 'machinePieceLink'] as $candidate) {
|
||||
$key = $candidate.'Id';
|
||||
if (!isset($payload[$key])) {
|
||||
continue;
|
||||
@@ -226,16 +230,20 @@ class CustomFieldValueController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
$entityType = strtolower($entityType);
|
||||
|
||||
if ('' === $entityType || '' === $entityId) {
|
||||
return $this->json(['success' => false, 'error' => 'Entity target is missing.'], 400);
|
||||
}
|
||||
|
||||
return match ($entityType) {
|
||||
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
|
||||
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
|
||||
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
|
||||
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
|
||||
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
|
||||
'machine' => $this->resolveEntity('machine', $entityId, $this->machineRepository),
|
||||
'composant' => $this->resolveEntity('composant', $entityId, $this->composantRepository),
|
||||
'piece' => $this->resolveEntity('piece', $entityId, $this->pieceRepository),
|
||||
'product' => $this->resolveEntity('product', $entityId, $this->productRepository),
|
||||
'machinecomponentlink' => $this->resolveEntity('machineComponentLink', $entityId, $this->machineComponentLinkRepository),
|
||||
'machinepiecelink' => $this->resolveEntity('machinePieceLink', $entityId, $this->machinePieceLinkRepository),
|
||||
default => $this->json(['success' => false, 'error' => 'Unsupported entity type.'], 400),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,6 +278,18 @@ class CustomFieldValueController extends AbstractController
|
||||
case 'product':
|
||||
$value->setProduct($entity);
|
||||
|
||||
break;
|
||||
|
||||
case 'machineComponentLink':
|
||||
$value->setMachineComponentLink($entity);
|
||||
$value->setComposant($entity->getComposant());
|
||||
|
||||
break;
|
||||
|
||||
case 'machinePieceLink':
|
||||
$value->setMachinePieceLink($entity);
|
||||
$value->setPiece($entity->getPiece());
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,9 @@ class MachineStructureController extends AbstractController
|
||||
// Copy product links
|
||||
$this->cloneProductLinks($source, $newMachine, $componentLinkMap, $pieceLinkMap);
|
||||
|
||||
// Copy context field values
|
||||
$this->cloneContextFieldValues($componentLinkMap, $pieceLinkMap);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$componentLinks = $this->machineComponentLinkRepository->findBy(['machine' => $newMachine], ['createdAt' => 'ASC']);
|
||||
@@ -188,6 +191,7 @@ class MachineStructureController extends AbstractController
|
||||
$newCf->setDefaultValue($cf->getDefaultValue());
|
||||
$newCf->setOptions($cf->getOptions());
|
||||
$newCf->setOrderIndex($cf->getOrderIndex());
|
||||
$newCf->setMachineContextOnly($cf->isMachineContextOnly());
|
||||
$newCf->setMachine($target);
|
||||
$this->entityManager->persist($newCf);
|
||||
|
||||
@@ -313,6 +317,45 @@ 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
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
@@ -606,13 +649,14 @@ class MachineStructureController extends AbstractController
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'defaultValue' => $customField->getDefaultValue(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
'id' => $customField->getId(),
|
||||
'name' => $customField->getName(),
|
||||
'type' => $customField->getType(),
|
||||
'required' => $customField->isRequired(),
|
||||
'options' => $customField->getOptions(),
|
||||
'defaultValue' => $customField->getDefaultValue(),
|
||||
'orderIndex' => $customField->getOrderIndex(),
|
||||
'machineContextOnly' => $customField->isMachineContextOnly(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -623,20 +667,27 @@ class MachineStructureController extends AbstractController
|
||||
{
|
||||
return array_map(function (MachineComponentLink $link): array {
|
||||
$composant = $link->getComposant();
|
||||
$modelType = $link->getModelType();
|
||||
$parentLink = $link->getParentLink();
|
||||
$type = $composant?->getTypeComposant();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant?->getId(),
|
||||
'composant' => $composant ? $this->normalizeComposant($composant) : null,
|
||||
'modelTypeId' => $modelType?->getId(),
|
||||
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
|
||||
'pendingEntity' => null === $composant,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()?->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getComponentCustomFields()) : [],
|
||||
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
@@ -645,19 +696,26 @@ class MachineStructureController extends AbstractController
|
||||
{
|
||||
return array_map(function (MachinePieceLink $link): array {
|
||||
$piece = $link->getPiece();
|
||||
$modelType = $link->getModelType();
|
||||
$parentLink = $link->getParentLink();
|
||||
$type = $piece?->getTypePiece();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece?->getId(),
|
||||
'piece' => $piece ? $this->normalizePiece($piece) : null,
|
||||
'modelTypeId' => $modelType?->getId(),
|
||||
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
|
||||
'pendingEntity' => null === $piece,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()?->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1,
|
||||
'contextCustomFields' => $type ? $this->normalizeContextCustomFieldDefinitions($type->getPieceCustomFields()) : [],
|
||||
'contextCustomFieldValues' => $this->normalizeCustomFieldValues($link->getContextFieldValues()),
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
@@ -665,13 +723,16 @@ class MachineStructureController extends AbstractController
|
||||
private function resolvePieceQuantity(MachinePieceLink $link): int
|
||||
{
|
||||
$parentLink = $link->getParentLink();
|
||||
$piece = $link->getPiece();
|
||||
|
||||
if (!$parentLink) {
|
||||
if (!$parentLink || !$piece) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
$composant = $parentLink->getComposant();
|
||||
$piece = $link->getPiece();
|
||||
if (!$composant) {
|
||||
return $link->getQuantity();
|
||||
}
|
||||
|
||||
foreach ($composant->getPieceSlots() as $slot) {
|
||||
if ($slot->getSelectedPiece()?->getId() === $piece->getId()) {
|
||||
@@ -685,14 +746,18 @@ class MachineStructureController extends AbstractController
|
||||
private function normalizeProductLinks(array $links): array
|
||||
{
|
||||
return array_map(function (MachineProductLink $link): array {
|
||||
$product = $link->getProduct();
|
||||
$product = $link->getProduct();
|
||||
$modelType = $link->getModelType();
|
||||
|
||||
return [
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'productId' => $product->getId(),
|
||||
'product' => $this->normalizeProduct($product),
|
||||
'productId' => $product?->getId(),
|
||||
'product' => $product ? $this->normalizeProduct($product) : null,
|
||||
'modelTypeId' => $modelType?->getId(),
|
||||
'modelType' => $modelType ? $this->normalizeModelType($modelType) : null,
|
||||
'pendingEntity' => null === $product,
|
||||
'parentLinkId' => $link->getParentLink()?->getId(),
|
||||
'parentComponentLinkId' => $link->getParentComponentLink()?->getId(),
|
||||
'parentPieceLinkId' => $link->getParentPieceLink()?->getId(),
|
||||
@@ -728,6 +793,7 @@ class MachineStructureController extends AbstractController
|
||||
$pieceData = [
|
||||
'slotId' => $slot->getId(),
|
||||
'typePieceId' => $slot->getTypePiece()?->getId(),
|
||||
'typePiece' => $this->normalizeModelType($slot->getTypePiece()),
|
||||
'quantity' => $slot->getQuantity(),
|
||||
'selectedPieceId' => $slot->getSelectedPiece()?->getId(),
|
||||
];
|
||||
@@ -843,13 +909,14 @@ class MachineStructureController extends AbstractController
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -870,13 +937,14 @@ class MachineStructureController extends AbstractController
|
||||
'id' => $cfv->getId(),
|
||||
'value' => $cfv->getValue(),
|
||||
'customField' => [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -884,6 +952,30 @@ class MachineStructureController extends AbstractController
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeContextCustomFieldDefinitions(Collection $customFields): array
|
||||
{
|
||||
$items = [];
|
||||
foreach ($customFields as $cf) {
|
||||
if (!$cf instanceof CustomField || !$cf->isMachineContextOnly()) {
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'machineContextOnly' => true,
|
||||
];
|
||||
}
|
||||
|
||||
usort($items, static fn (array $a, array $b) => $a['orderIndex'] <=> $b['orderIndex']);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function normalizeOverrides(object $link): ?array
|
||||
{
|
||||
$name = method_exists($link, 'getNameOverride') ? $link->getNameOverride() : null;
|
||||
|
||||
58
src/Controller/MaintenanceController.php
Normal file
58
src/Controller/MaintenanceController.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
final class MaintenanceController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
) {}
|
||||
|
||||
#[Route('/api/maintenance/check', name: 'maintenance_check', methods: ['GET'])]
|
||||
public function check(): JsonResponse
|
||||
{
|
||||
return new JsonResponse([
|
||||
'enabled' => file_exists($this->flagPath()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/admin/maintenance', name: 'admin_maintenance_status', methods: ['GET'])]
|
||||
public function status(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
return new JsonResponse([
|
||||
'enabled' => file_exists($this->flagPath()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/api/admin/maintenance', name: 'admin_maintenance_toggle', methods: ['PUT'])]
|
||||
public function toggle(): JsonResponse
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
$path = $this->flagPath();
|
||||
|
||||
if (file_exists($path)) {
|
||||
unlink($path);
|
||||
$enabled = false;
|
||||
} else {
|
||||
file_put_contents($path, (string) time());
|
||||
$enabled = true;
|
||||
}
|
||||
|
||||
return new JsonResponse(['enabled' => $enabled]);
|
||||
}
|
||||
|
||||
private function flagPath(): string
|
||||
{
|
||||
return $this->kernel->getProjectDir().'/var/maintenance';
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,10 @@ class CustomField
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private bool $required = false;
|
||||
|
||||
#[ORM\Column(type: Types::BOOLEAN, options: ['default' => false], name: 'machinecontextonly')]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private bool $machineContextOnly = false;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'defaultValue')]
|
||||
private ?string $defaultValue = null;
|
||||
|
||||
@@ -220,4 +224,16 @@ class CustomField
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isMachineContextOnly(): bool
|
||||
{
|
||||
return $this->machineContextOnly;
|
||||
}
|
||||
|
||||
public function setMachineContextOnly(bool $machineContextOnly): static
|
||||
{
|
||||
$this->machineContextOnly = $machineContextOnly;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,14 @@ class CustomFieldValue
|
||||
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Product $product = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(name: 'machinecomponentlinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachineComponentLink $machineComponentLink = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachinePieceLink::class, inversedBy: 'contextFieldValues')]
|
||||
#[ORM\JoinColumn(name: 'machinepiecelinkid', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?MachinePieceLink $machinePieceLink = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, name: 'createdAt')]
|
||||
#[Groups(['composant:read', 'piece:read', 'product:read', 'machine:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -151,4 +159,28 @@ class CustomFieldValue
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMachineComponentLink(): ?MachineComponentLink
|
||||
{
|
||||
return $this->machineComponentLink;
|
||||
}
|
||||
|
||||
public function setMachineComponentLink(?MachineComponentLink $machineComponentLink): static
|
||||
{
|
||||
$this->machineComponentLink = $machineComponentLink;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getMachinePieceLink(): ?MachinePieceLink
|
||||
{
|
||||
return $this->machinePieceLink;
|
||||
}
|
||||
|
||||
public function setMachinePieceLink(?MachinePieceLink $machinePieceLink): static
|
||||
{
|
||||
$this->machinePieceLink = $machinePieceLink;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[ORM\Entity(repositoryClass: MachineRepository::class)]
|
||||
#[ORM\Table(name: 'machines')]
|
||||
#[ORM\HasLifecycleCallbacks]
|
||||
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.')]
|
||||
#[UniqueEntity(fields: ['reference'], message: 'Une machine avec cette référence existe déjà.', ignoreNull: true)]
|
||||
#[ApiResource(
|
||||
description: 'Machines industrielles rattachées à un site. Chaque machine possède une structure hiérarchique de composants, pièces et produits, ainsi que des champs personnalisés et des documents.',
|
||||
operations: [
|
||||
@@ -150,7 +150,7 @@ class Machine
|
||||
|
||||
public function setReference(?string $reference): static
|
||||
{
|
||||
$this->reference = $reference;
|
||||
$this->reference = (null !== $reference && '' !== trim($reference)) ? $reference : null;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,12 @@ class MachineComponentLink
|
||||
private Machine $machine;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Composant::class, inversedBy: 'machineLinks')]
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Composant $composant;
|
||||
#[ORM\JoinColumn(name: 'composantId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Composant $composant = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $modelType = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'childLinks')]
|
||||
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
@@ -71,6 +75,12 @@ class MachineComponentLink
|
||||
#[ORM\OneToMany(mappedBy: 'parentComponentLink', targetEntity: MachineProductLink::class)]
|
||||
private Collection $productLinks;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomFieldValue>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'machineComponentLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contextFieldValues;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'nameOverride')]
|
||||
private ?string $nameOverride = null;
|
||||
|
||||
@@ -88,11 +98,12 @@ class MachineComponentLink
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->childLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->childLinks = new ArrayCollection();
|
||||
$this->pieceLinks = new ArrayCollection();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
$this->contextFieldValues = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getMachine(): Machine
|
||||
@@ -107,18 +118,30 @@ class MachineComponentLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComposant(): Composant
|
||||
public function getComposant(): ?Composant
|
||||
{
|
||||
return $this->composant;
|
||||
}
|
||||
|
||||
public function setComposant(Composant $composant): static
|
||||
public function setComposant(?Composant $composant): static
|
||||
{
|
||||
$this->composant = $composant;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModelType(): ?ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(?ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParentLink(): ?MachineComponentLink
|
||||
{
|
||||
return $this->parentLink;
|
||||
@@ -166,4 +189,12 @@ class MachineComponentLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomFieldValue>
|
||||
*/
|
||||
public function getContextFieldValues(): Collection
|
||||
{
|
||||
return $this->contextFieldValues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,12 @@ class MachinePieceLink
|
||||
private Machine $machine;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Piece::class, inversedBy: 'machineLinks')]
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Piece $piece;
|
||||
#[ORM\JoinColumn(name: 'pieceId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Piece $piece = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $modelType = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachineComponentLink::class, inversedBy: 'pieceLinks')]
|
||||
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
@@ -60,6 +64,12 @@ class MachinePieceLink
|
||||
#[ORM\OneToMany(mappedBy: 'parentPieceLink', targetEntity: MachineProductLink::class)]
|
||||
private Collection $productLinks;
|
||||
|
||||
/**
|
||||
* @var Collection<int, CustomFieldValue>
|
||||
*/
|
||||
#[ORM\OneToMany(mappedBy: 'machinePieceLink', targetEntity: CustomFieldValue::class, cascade: ['persist', 'remove'], orphanRemoval: true)]
|
||||
private Collection $contextFieldValues;
|
||||
|
||||
#[ORM\Column(type: Types::STRING, length: 255, nullable: true, name: 'nameOverride')]
|
||||
private ?string $nameOverride = null;
|
||||
|
||||
@@ -81,9 +91,10 @@ class MachinePieceLink
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
$this->createdAt = new DateTimeImmutable();
|
||||
$this->updatedAt = new DateTimeImmutable();
|
||||
$this->productLinks = new ArrayCollection();
|
||||
$this->contextFieldValues = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getMachine(): Machine
|
||||
@@ -98,18 +109,30 @@ class MachinePieceLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPiece(): Piece
|
||||
public function getPiece(): ?Piece
|
||||
{
|
||||
return $this->piece;
|
||||
}
|
||||
|
||||
public function setPiece(Piece $piece): static
|
||||
public function setPiece(?Piece $piece): static
|
||||
{
|
||||
$this->piece = $piece;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModelType(): ?ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(?ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParentLink(): ?MachineComponentLink
|
||||
{
|
||||
return $this->parentLink;
|
||||
@@ -169,4 +192,12 @@ class MachinePieceLink
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, CustomFieldValue>
|
||||
*/
|
||||
public function getContextFieldValues(): Collection
|
||||
{
|
||||
return $this->contextFieldValues;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,12 @@ class MachineProductLink
|
||||
private Machine $machine;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Product::class, inversedBy: 'machineLinks')]
|
||||
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')]
|
||||
private Product $product;
|
||||
#[ORM\JoinColumn(name: 'productId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
private ?Product $product = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: ModelType::class)]
|
||||
#[ORM\JoinColumn(name: 'modelTypeId', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
|
||||
private ?ModelType $modelType = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: MachineProductLink::class, inversedBy: 'childLinks')]
|
||||
#[ORM\JoinColumn(name: 'parentLinkId', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
|
||||
@@ -92,18 +96,30 @@ class MachineProductLink
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProduct(): Product
|
||||
public function getProduct(): ?Product
|
||||
{
|
||||
return $this->product;
|
||||
}
|
||||
|
||||
public function setProduct(Product $product): static
|
||||
public function setProduct(?Product $product): static
|
||||
{
|
||||
$this->product = $product;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getModelType(): ?ModelType
|
||||
{
|
||||
return $this->modelType;
|
||||
}
|
||||
|
||||
public function setModelType(?ModelType $modelType): static
|
||||
{
|
||||
$this->modelType = $modelType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getParentLink(): ?MachineProductLink
|
||||
{
|
||||
return $this->parentLink;
|
||||
|
||||
@@ -421,13 +421,14 @@ class ModelType
|
||||
$items = [];
|
||||
foreach ($fields as $cf) {
|
||||
$items[] = [
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'id' => $cf->getId(),
|
||||
'name' => $cf->getName(),
|
||||
'type' => $cf->getType(),
|
||||
'required' => $cf->isRequired(),
|
||||
'options' => $cf->getOptions(),
|
||||
'defaultValue' => $cf->getDefaultValue(),
|
||||
'orderIndex' => $cf->getOrderIndex(),
|
||||
'machineContextOnly' => $cf->isMachineContextOnly(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
60
src/EventListener/MaintenanceModeListener.php
Normal file
60
src/EventListener/MaintenanceModeListener.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Event\RequestEvent;
|
||||
use Symfony\Component\HttpKernel\KernelInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
|
||||
#[AsEventListener(event: 'kernel.request', priority: 10)]
|
||||
final class MaintenanceModeListener
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KernelInterface $kernel,
|
||||
private readonly TokenStorageInterface $tokenStorage,
|
||||
) {}
|
||||
|
||||
public function __invoke(RequestEvent $event): void
|
||||
{
|
||||
if (!$event->isMainRequest()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$flagFile = $this->kernel->getProjectDir().'/var/maintenance';
|
||||
|
||||
if (!file_exists($flagFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$request = $event->getRequest();
|
||||
$path = $request->getPathInfo();
|
||||
|
||||
// Always allow maintenance status endpoint and session endpoints
|
||||
if (str_starts_with($path, '/api/admin/maintenance')
|
||||
|| str_starts_with($path, '/api/maintenance/check')
|
||||
|| str_starts_with($path, '/api/session')
|
||||
|| str_starts_with($path, '/api/health')
|
||||
|| str_starts_with($path, '/api/docs')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow admin users through
|
||||
$token = $this->tokenStorage->getToken();
|
||||
if ($token && $token->getUser()) {
|
||||
$roles = $token->getRoleNames();
|
||||
if (in_array('ROLE_ADMIN', $roles, true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$event->setResponse(new JsonResponse(
|
||||
['message' => 'Application en maintenance. Veuillez réessayer ultérieurement.'],
|
||||
JsonResponse::HTTP_SERVICE_UNAVAILABLE,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -74,18 +74,20 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
foreach ($entity->getComponentLinks() as $link) {
|
||||
$componentLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'composantId' => $link->getComposant()->getId(),
|
||||
'composantName' => $link->getComposant()->getName(),
|
||||
'composantId' => $link->getComposant()?->getId(),
|
||||
'composantName' => $link->getComposant()?->getName(),
|
||||
'modelTypeId' => $link->getModelType()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
$pieceLinks = [];
|
||||
foreach ($entity->getPieceLinks() as $link) {
|
||||
$pieceLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'pieceId' => $link->getPiece()->getId(),
|
||||
'pieceName' => $link->getPiece()->getName(),
|
||||
'quantity' => $link->getQuantity(),
|
||||
'id' => $link->getId(),
|
||||
'pieceId' => $link->getPiece()?->getId(),
|
||||
'pieceName' => $link->getPiece()?->getName(),
|
||||
'quantity' => $link->getQuantity(),
|
||||
'modelTypeId' => $link->getModelType()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -93,8 +95,9 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
foreach ($entity->getProductLinks() as $link) {
|
||||
$productLinks[] = [
|
||||
'id' => $link->getId(),
|
||||
'productId' => $link->getProduct()->getId(),
|
||||
'productName' => $link->getProduct()->getName(),
|
||||
'productId' => $link->getProduct()?->getId(),
|
||||
'productName' => $link->getProduct()?->getName(),
|
||||
'modelTypeId' => $link->getModelType()?->getId(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -187,8 +190,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Component',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getComposant()->getId(),
|
||||
'name' => $entity->getComposant()->getName(),
|
||||
'id' => $entity->getComposant()?->getId() ?? $entity->getModelType()?->getId(),
|
||||
'name' => $entity->getComposant()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -198,8 +201,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Piece',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getPiece()->getId(),
|
||||
'name' => $entity->getPiece()->getName(),
|
||||
'id' => $entity->getPiece()?->getId() ?? $entity->getModelType()?->getId(),
|
||||
'name' => $entity->getPiece()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule',
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -209,8 +212,8 @@ final class MachineAuditSubscriber extends AbstractAuditSubscriber
|
||||
'machine' => $entity->getMachine(),
|
||||
'diffKey' => $action.'Product',
|
||||
'diffValue' => [
|
||||
'id' => $entity->getProduct()->getId(),
|
||||
'name' => $entity->getProduct()->getName(),
|
||||
'id' => $entity->getProduct()?->getId() ?? $entity->getModelType()?->getId(),
|
||||
'name' => $entity->getProduct()?->getName() ?? $entity->getModelType()?->getName() ?? 'Catégorie seule',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -164,11 +164,11 @@ class MachineStructureTool
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'composantId' => $composant->getId(),
|
||||
'composant' => $this->normalizeComposant($composant),
|
||||
'composantId' => $composant?->getId(),
|
||||
'composant' => $composant ? $this->normalizeComposant($composant) : null,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()?->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'childLinks' => [],
|
||||
'pieceLinks' => [],
|
||||
@@ -189,13 +189,13 @@ class MachineStructureTool
|
||||
'id' => $link->getId(),
|
||||
'linkId' => $link->getId(),
|
||||
'machineId' => $link->getMachine()->getId(),
|
||||
'pieceId' => $piece->getId(),
|
||||
'piece' => $this->normalizePiece($piece),
|
||||
'pieceId' => $piece?->getId(),
|
||||
'piece' => $piece ? $this->normalizePiece($piece) : null,
|
||||
'parentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentLinkId' => $parentLink?->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()->getId(),
|
||||
'parentComponentId' => $parentLink?->getComposant()?->getId(),
|
||||
'overrides' => $this->normalizeOverrides($link),
|
||||
'quantity' => $this->resolvePieceQuantity($link),
|
||||
'quantity' => $piece ? $this->resolvePieceQuantity($link) : 1,
|
||||
];
|
||||
}, $links);
|
||||
}
|
||||
|
||||
@@ -869,22 +869,25 @@ final class EntityVersionService
|
||||
$snapshot['componentLinks'] = [];
|
||||
foreach ($entity->getComponentLinks() as $link) {
|
||||
$snapshot['componentLinks'][] = [
|
||||
'id' => $link->getId(), 'composantId' => $link->getComposant()->getId(),
|
||||
'composantName' => $link->getComposant()->getName(),
|
||||
'id' => $link->getId(), 'composantId' => $link->getComposant()?->getId(),
|
||||
'composantName' => $link->getComposant()?->getName(),
|
||||
'modelTypeId' => $link->getModelType()?->getId(),
|
||||
];
|
||||
}
|
||||
$snapshot['pieceLinks'] = [];
|
||||
foreach ($entity->getPieceLinks() as $link) {
|
||||
$snapshot['pieceLinks'][] = [
|
||||
'id' => $link->getId(), 'pieceId' => $link->getPiece()->getId(),
|
||||
'pieceName' => $link->getPiece()->getName(), 'quantity' => $link->getQuantity(),
|
||||
'id' => $link->getId(), 'pieceId' => $link->getPiece()?->getId(),
|
||||
'pieceName' => $link->getPiece()?->getName(), 'quantity' => $link->getQuantity(),
|
||||
'modelTypeId' => $link->getModelType()?->getId(),
|
||||
];
|
||||
}
|
||||
$snapshot['productLinks'] = [];
|
||||
foreach ($entity->getProductLinks() as $link) {
|
||||
$snapshot['productLinks'][] = [
|
||||
'id' => $link->getId(), 'productId' => $link->getProduct()->getId(),
|
||||
'productName' => $link->getProduct()->getName(),
|
||||
'id' => $link->getId(), 'productId' => $link->getProduct()?->getId(),
|
||||
'productName' => $link->getProduct()?->getName(),
|
||||
'modelTypeId' => $link->getModelType()?->getId(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user