Compare commits
294 Commits
feature/do
...
v1.9.8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
044b64152c | ||
|
|
4de3ffa0e0 | ||
|
|
5bdf578de9 | ||
|
|
bc1b757a96 | ||
|
|
24b664e85b | ||
|
|
8565e68062 | ||
|
|
a8a95b16a9 | ||
|
|
68b394fc14 | ||
|
|
2ceb49db9f | ||
|
|
8ad0f26249 | ||
|
|
be859e57db | ||
|
|
73b2cf300d | ||
|
|
974a4a0781 | ||
|
|
faa7511cab | ||
|
|
958a00c8fc | ||
|
|
e0f761da2b | ||
|
|
80739a4528 | ||
|
|
c5988ec7a6 | ||
|
|
63a56c47ba | ||
|
|
c82c21c0cd | ||
|
|
a339e722a6 | ||
|
|
a7415964a7 | ||
|
|
767c9a7424 | ||
|
|
d197d30eb0 | ||
|
|
452de8b069 | ||
|
|
92141c6564 | ||
|
|
9e1504ddb7 | ||
|
|
a72279f978 | ||
|
|
9cc8b28122 | ||
|
|
02ca3549d5 | ||
|
|
5485bac339 | ||
|
|
d0dc01deb1 | ||
|
|
a76f25321a | ||
|
|
2410ebb7dc | ||
|
|
1d6c520945 | ||
|
|
10ad7b7f41 | ||
|
|
aebe7ed586 | ||
|
|
5b42bf1504 | ||
|
|
5ab63e8b27 | ||
|
|
4db832bc8c | ||
|
|
736a8bccf9 | ||
|
|
bd69b37524 | ||
|
|
e7402dda4d | ||
|
|
6b0d2d1b0a | ||
|
|
7a4a77e3fc | ||
|
|
2e82e854bf | ||
|
|
ac860d3165 | ||
|
|
8176635eb8 | ||
|
|
a730a18794 | ||
|
|
40d0753637 | ||
|
|
db630e315b | ||
|
|
53530dc16d | ||
|
|
974b74ee9f | ||
|
|
ab05ce589d | ||
|
|
ce3f081a0a | ||
|
|
63fba4138e | ||
|
|
d58a8c2479 | ||
|
|
81f7b1a9ac | ||
|
|
9e303426a7 | ||
|
|
d4fc0f1fee | ||
|
|
f8403ddfbc | ||
|
|
428da471d1 | ||
|
|
271844efb1 | ||
|
|
07cad19988 | ||
|
|
8dacad7a59 | ||
|
|
5912216a89 | ||
|
|
139ba183de | ||
|
|
9fef009610 | ||
|
|
4a3bceffa1 | ||
|
|
50d8dde6d5 | ||
|
|
9b40f9f2c7 | ||
|
|
721963449b | ||
|
|
22ba9a8d05 | ||
|
|
695d56a6d3 | ||
|
|
5c31045e83 | ||
|
|
b0124c11ba | ||
|
|
7e67b124f3 | ||
| 3ad326348b | |||
| 5b9c4ca09d | |||
| 6b5eb7bcd6 | |||
| 98f5d983b3 | |||
| cda872a057 | |||
| 84970a352d | |||
| c1d14124ff | |||
| a83a4428c2 | |||
| a1998d7966 | |||
| 6add558725 | |||
| e18ce984e7 | |||
| d00e5c058b | |||
| 3b24dc128a | |||
| c188bd7e8b | |||
| e911f169ce | |||
| 9f9ad80c61 | |||
| c831f65ef3 | |||
| 81eb181000 | |||
| a3fde7a191 | |||
| b696b5aa1f | |||
| c6db96dc76 | |||
| 165e0a6341 | |||
| de7be1b9d0 | |||
| 7b3eb1c5fc | |||
|
|
592beb0fa7 | ||
|
|
e732585e63 | ||
|
|
f1cc21c31b | ||
|
|
6c2f84dd3a | ||
|
|
032b3b33c9 | ||
|
|
32d03b480d | ||
|
|
6f1bac381d | ||
|
|
89dc2e93b8 | ||
|
|
8f5f25b3e7 | ||
|
|
c06c852493 | ||
|
|
41f5319b67 | ||
|
|
c7fd8328d6 | ||
|
|
55e2a4fafe | ||
|
|
e88ed5b8f2 | ||
|
|
546cc37a09 | ||
|
|
efd0fbe407 | ||
|
|
607f84fc3d | ||
|
|
a98ab8c275 | ||
|
|
e22463874c | ||
|
|
256039264e | ||
|
|
e459da7c20 | ||
|
|
e84b5cf674 | ||
|
|
cc70fe2b29 | ||
|
|
6bed715b7f | ||
|
|
dbf8c8856b | ||
|
|
62127a33f5 | ||
|
|
2fffe4a368 | ||
|
|
c9054e5b4d | ||
|
|
5cab15422d | ||
|
|
439db8117a | ||
|
|
675820532c | ||
|
|
4edfc55c37 | ||
|
|
480aaa24b2 | ||
|
|
185af65519 | ||
|
|
8fecf67a7f | ||
|
|
79d2df8bc6 | ||
|
|
23da4ba4c7 | ||
|
|
635b8f0461 | ||
|
|
bf74a50f57 | ||
|
|
7c44778f25 | ||
|
|
9f7dd12b34 | ||
|
|
67af3c9c46 | ||
|
|
634184c2be | ||
|
|
6152848957 | ||
|
|
046f464378 | ||
|
|
8700c253cd | ||
|
|
519fa3a8f4 | ||
|
|
e1594cab76 | ||
|
|
daaa1c4cb9 | ||
|
|
786b1d91f6 | ||
|
|
3436cd0b90 | ||
|
|
efe1fd2a73 | ||
|
|
a6664ce9a2 | ||
|
|
399ec1f7b4 | ||
|
|
86bb8af32d | ||
|
|
78718b85ae | ||
|
|
9ee348fff0 | ||
|
|
1fbd1d1b2e | ||
|
|
1f2d6c78e8 | ||
|
|
649f8ca9cc | ||
| 3705b8daed | |||
|
|
202b964b24 | ||
|
|
a1d15c23a4 | ||
|
|
a7101c7e77 | ||
|
|
adccfa9b46 | ||
|
|
5f54acdfac | ||
|
|
94239031d6 | ||
|
|
b27662d2bc | ||
|
|
55739fe50f | ||
|
|
1f5f1509a9 | ||
|
|
a8cb4d1ac0 | ||
| 8af8374282 | |||
| 9cc7ac10f0 | |||
|
|
86d15faa01 | ||
|
|
603c03ca00 | ||
|
|
155cd9b358 | ||
| 2f3d4c5260 | |||
| 51edd7f655 | |||
| 2e4d61c3ea | |||
| 52f75c5301 | |||
| 84048bf3a2 | |||
| 0bfb69ad13 | |||
| ddce3ff3ae | |||
| b5af7f13b6 | |||
| e99f053233 | |||
|
|
936a73fde3 | ||
|
|
34af59d054 | ||
|
|
d860f24e69 | ||
|
|
3af6c50892 | ||
|
|
dc2bc6c70a | ||
|
|
ef9a8b5b7b | ||
|
|
53dab13489 | ||
|
|
f59255e684 | ||
|
|
76cd3fac98 | ||
|
|
4c714b3647 | ||
|
|
b752fba69a | ||
|
|
da447e4ea2 | ||
|
|
5684bc282b | ||
|
|
e9c7a3d1a7 | ||
|
|
d011e58030 | ||
|
|
325bdb3d6f | ||
|
|
417b34b45e | ||
|
|
553600c34b | ||
|
|
42c788103a | ||
|
|
761c5f559a | ||
|
|
4ccc19505f | ||
|
|
8eada12438 | ||
|
|
ebc02f41d9 | ||
|
|
62b5c9b297 | ||
|
|
e297d1bb39 | ||
|
|
469bcb82d1 | ||
|
|
06ae0ca7aa | ||
|
|
95c2a82689 | ||
|
|
f89364d04e | ||
|
|
bc8823a776 | ||
|
|
14e8faf3a1 | ||
|
|
c5cd75a19f | ||
|
|
f9641dbd62 | ||
|
|
082b1ccc05 | ||
|
|
384c3f0680 | ||
|
|
c5f2c568b6 | ||
|
|
386a1c9d1b | ||
|
|
d3f8ac3649 | ||
|
|
a2b2151222 | ||
|
|
7f19d9ba4e | ||
|
|
25d2aa1bcc | ||
|
|
84bc99d8ec | ||
|
|
7b2e509b04 | ||
|
|
9a55e29b74 | ||
|
|
fcab426f8a | ||
|
|
1d1e0b7bd0 | ||
|
|
fd60cbbbfe | ||
|
|
c489f093ed | ||
|
|
43b615ac3e | ||
|
|
a78938a4d1 | ||
|
|
b7caa4f552 | ||
|
|
d1ce074c6d | ||
|
|
cab9b216e6 | ||
|
|
8e3894bfe2 | ||
|
|
801fe5be95 | ||
|
|
0d2748f660 | ||
|
|
e25e8c2669 | ||
|
|
f9de94907b | ||
|
|
041478e9d4 | ||
|
|
a4840c454f | ||
|
|
ac0687ac8f | ||
|
|
7980aa186b | ||
|
|
bdae2621c5 | ||
|
|
f924c65ab8 | ||
|
|
83b3e33b1e | ||
|
|
c1e170b088 | ||
|
|
5501b3b5ef | ||
|
|
ae103a38be | ||
|
|
57a08bb8c9 | ||
|
|
ee659c4e16 | ||
|
|
9095cfd054 | ||
|
|
936a9d74ca | ||
|
|
e33e91ee26 | ||
|
|
b0c3b2b646 | ||
|
|
32dd8fab58 | ||
|
|
dec4d451bb | ||
|
|
367e356765 | ||
|
|
b568b22461 | ||
|
|
94f296c64b | ||
|
|
42f0f939e8 | ||
|
|
87cd5e8b2a | ||
|
|
a8fab0d718 | ||
|
|
45356ec3ae | ||
|
|
ac948bbf5e | ||
|
|
4787c1ea8f | ||
|
|
95c1e66520 | ||
|
|
316dcb6339 | ||
|
|
37c66ac3d6 | ||
|
|
8a32ef4bbc | ||
|
|
0a95b90553 | ||
|
|
3c0c22ad0f | ||
|
|
0fbf77ab43 | ||
|
|
c63b543c74 | ||
|
|
dd0ef12b46 | ||
|
|
d605f2418f | ||
|
|
4d2d35f360 | ||
|
|
95da7c72db | ||
|
|
c33a04b68e | ||
|
|
74b78137a0 | ||
|
|
36a44848d2 | ||
|
|
d99768bc94 | ||
|
|
8965ee97a3 | ||
|
|
23e2397de6 | ||
|
|
ca44a78aad | ||
|
|
9fb0353442 | ||
|
|
36b7ce93ec | ||
|
|
1ca5c347b7 | ||
|
|
7613374e1f |
@@ -6,9 +6,9 @@ docker/
|
||||
deploy/docker/docker-compose.prod.yml
|
||||
deploy/docker/deploy.sh
|
||||
deploy/docker/.env.example
|
||||
Inventory_frontend/node_modules
|
||||
Inventory_frontend/.nuxt
|
||||
Inventory_frontend/.output
|
||||
frontend/node_modules
|
||||
frontend/.nuxt
|
||||
frontend/.output
|
||||
var/
|
||||
vendor/
|
||||
LOG/
|
||||
@@ -21,4 +21,4 @@ scripts/
|
||||
*.md
|
||||
!composer.lock
|
||||
!symfony.lock
|
||||
!Inventory_frontend/package-lock.json
|
||||
!frontend/package-lock.json
|
||||
|
||||
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"
|
||||
@@ -11,8 +11,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
run: |
|
||||
|
||||
67
.gitea/workflows/release-artefact.yml
Normal file
67
.gitea/workflows/release-artefact.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: Build Release Artefact
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.4"
|
||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install backend deps (prod)
|
||||
env:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: "0"
|
||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
|
||||
- name: Build frontend (static)
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE_URL=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
||||
test -f .output/public/index.html
|
||||
|
||||
- name: Build artefact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/inventory-${GITHUB_REF_NAME}.tar.gz" \
|
||||
.env \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
templates \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
symfony.lock \
|
||||
VERSION \
|
||||
frontend/.output
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/inventory-${{ github.ref_name }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -34,10 +34,6 @@ FEATURE_IDEAS.md
|
||||
bin/.phpunit.result.cache
|
||||
###< temp files ###
|
||||
|
||||
###> frontend ###
|
||||
/frontend/
|
||||
###< frontend ###
|
||||
|
||||
###> ide ###
|
||||
/.idea/
|
||||
###< ide ###
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -1,3 +0,0 @@
|
||||
[submodule "Inventory_frontend"]
|
||||
path = Inventory_frontend
|
||||
url = gitea@gitea.malio.fr:MALIO-DEV/Inventory_frontend.git
|
||||
@@ -43,7 +43,7 @@ Inventory/ # Backend Symfony (repo principal)
|
||||
├── pre-commit, commit-msg # Git hooks
|
||||
├── makefile # Commandes Docker/dev
|
||||
├── VERSION # Source unique de version (semver)
|
||||
├── Inventory_frontend/ # ← SUBMODULE GIT (repo séparé)
|
||||
├── frontend/ # ← SUBMODULE GIT (repo séparé)
|
||||
│ ├── app/pages/ # Pages Nuxt (file-based routing)
|
||||
│ ├── app/components/ # Composants Vue (auto-imported)
|
||||
│ ├── app/composables/ # Composables Vue
|
||||
@@ -67,7 +67,7 @@ make test FILES=tests/Api/Entity/MachineTest.php # Un test spécifique
|
||||
make php-cs-fixer-allow-risky # Linter PHP (cs-fixer)
|
||||
docker exec -u www-data php-inventory-apache php bin/console doctrine:migrations:migrate
|
||||
|
||||
# Frontend (dans Inventory_frontend/)
|
||||
# Frontend (dans frontend/)
|
||||
npm run dev # Dev server (port 3001)
|
||||
npm run build # Build production
|
||||
npm run lint:fix # ESLint fix
|
||||
@@ -109,7 +109,7 @@ Exemples :
|
||||
|
||||
### Submodule Workflow
|
||||
Le frontend est un submodule git. Lors d'un commit frontend :
|
||||
1. Commit dans `Inventory_frontend/` d'abord
|
||||
1. Commit dans `frontend/` d'abord
|
||||
2. Commit dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Push les deux repos
|
||||
|
||||
|
||||
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
|
||||
|
||||
Submodule Inventory_frontend deleted from 958a00c8fc
@@ -166,7 +166,7 @@ Inventory/ # Backend Symfony (repo principal)
|
||||
├── docker/ # Dockerfile + config Docker
|
||||
├── makefile # Commandes de dev raccourcies
|
||||
├── VERSION # Version courante (ex: 1.8.1)
|
||||
└── Inventory_frontend/ # Submodule git (frontend, repo séparé)
|
||||
└── frontend/ # Submodule git (frontend, repo séparé)
|
||||
├── app/pages/ # Les pages de l'app (1 fichier = 1 route URL)
|
||||
├── app/components/ # Composants Vue réutilisables
|
||||
├── app/composables/ # Logique métier partagée (appels API, états)
|
||||
@@ -289,9 +289,9 @@ Le hook `pre-commit` s'exécute automatiquement avant chaque commit :
|
||||
|
||||
### Submodule frontend
|
||||
|
||||
Le frontend est un **submodule git** dans `Inventory_frontend/` (c'est un repo git séparé, inclus dans le repo principal). Workflow de commit :
|
||||
Le frontend est un **submodule git** dans `frontend/` (c'est un repo git séparé, inclus dans le repo principal). Workflow de commit :
|
||||
|
||||
1. Commiter dans `Inventory_frontend/` d'abord
|
||||
1. Commiter dans `frontend/` d'abord
|
||||
2. Commiter dans le repo principal pour mettre à jour le pointeur du submodule
|
||||
3. Pousser les deux repos
|
||||
|
||||
@@ -302,4 +302,4 @@ Le frontend est un **submodule git** dans `Inventory_frontend/` (c'est un repo g
|
||||
- **[DEPLOY.md](DEPLOY.md)** : guide de déploiement serveur (Nginx, PHP-FPM, PostgreSQL)
|
||||
- **[RELEASE.md](RELEASE.md)** : processus de release et versioning
|
||||
- **[CHANGELOG.md](CHANGELOG.md)** : historique des versions
|
||||
- **[Frontend README](Inventory_frontend/README.md)** : documentation du frontend Nuxt
|
||||
- **[Frontend README](frontend/README.md)** : documentation du frontend Nuxt
|
||||
|
||||
@@ -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:
|
||||
|
||||
2
config/version.yaml
Normal file
2
config/version.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
parameters:
|
||||
app.version: '1.9.8'
|
||||
@@ -19,18 +19,18 @@ COPY migrations migrations/
|
||||
COPY public public/
|
||||
COPY src src/
|
||||
COPY templates templates/
|
||||
COPY VERSION VERSION
|
||||
|
||||
RUN composer dump-autoload --optimize --no-dev
|
||||
|
||||
# --- Stage 2: Build frontend ---
|
||||
FROM node:lts-alpine AS frontend-build
|
||||
|
||||
WORKDIR /app/Inventory_frontend
|
||||
COPY Inventory_frontend/package.json Inventory_frontend/package-lock.json ./
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY Inventory_frontend/ ./
|
||||
COPY frontend/ ./
|
||||
COPY config/version.yaml /app/config/version.yaml
|
||||
ENV CI=1 \
|
||||
NUXT_TELEMETRY_DISABLED=1 \
|
||||
NUXT_PUBLIC_API_BASE_URL=/api \
|
||||
@@ -68,7 +68,7 @@ COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/inventory.conf
|
||||
COPY --from=backend-build /app /var/www/html
|
||||
|
||||
# Frontend from stage 2
|
||||
COPY --from=frontend-build /app/Inventory_frontend/.output/public /var/www/html/Inventory_frontend/.output/public
|
||||
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
|
||||
|
||||
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
@@ -24,5 +24,5 @@ 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)
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@ server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html/Inventory_frontend/.output/public;
|
||||
root /var/www/html/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
access_log /dev/stdout;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
29
frontend/.gitignore
vendored
Normal file
29
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Playwright
|
||||
e2e/.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
155
frontend/README.md
Normal file
155
frontend/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Inventory Frontend
|
||||
|
||||
Interface web de gestion d'inventaire industriel pour **Malio**. Application SPA complète permettant la gestion du parc machines, des pièces, composants, produits, fournisseurs et documents associés.
|
||||
|
||||
## Stack technique
|
||||
|
||||
| Technologie | Version | Rôle |
|
||||
|-------------|---------|------|
|
||||
| [Nuxt](https://nuxt.com) | 4 | Framework (SPA, SSR désactivé) |
|
||||
| [Vue 3](https://vuejs.org) | 3.5 | Composition API + `<script setup>` |
|
||||
| [TypeScript](https://www.typescriptlang.org) | 5.7 | Typage strict sur l'ensemble du projet |
|
||||
| [TailwindCSS](https://tailwindcss.com) | 4 | Utility-first CSS |
|
||||
| [DaisyUI](https://daisyui.com) | 5 | Composants UI (alertes, modales, badges, etc.) |
|
||||
| [Lucide](https://lucide.dev) | via unplugin-icons | Icônes SVG |
|
||||
| [Vitest](https://vitest.dev) | 4 | Tests unitaires |
|
||||
| [Playwright](https://playwright.dev) | 1.58 | Tests E2E |
|
||||
|
||||
## Prérequis
|
||||
|
||||
- **Node.js** >= 20
|
||||
- **npm**
|
||||
- **Backend Symfony** démarré avec l'API sur `http://localhost:8081/api`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
L'application est accessible sur **http://localhost:3001**.
|
||||
|
||||
## Commandes disponibles
|
||||
|
||||
| Commande | Description |
|
||||
|----------|-------------|
|
||||
| `npm run dev` | Serveur de développement avec HMR |
|
||||
| `npm run build` | Build de production |
|
||||
| `npm run lint:fix` | Correction automatique ESLint |
|
||||
| `npx nuxi typecheck` | Vérification TypeScript (0 erreurs attendu) |
|
||||
| `npm run test` | Tests unitaires Vitest |
|
||||
| `npm run test:watch` | Tests unitaires en mode watch |
|
||||
| `npm run test:e2e` | Tests E2E Playwright (Chrome) |
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
### Gestion du parc
|
||||
|
||||
- **Machines** : création, édition, vue détaillée avec structure hiérarchique (composants, pièces, produits)
|
||||
- **Squelettes machines** : templates réutilisables pour créer des machines à partir d'un modèle type
|
||||
- **Sites** : gestion multi-sites avec coordonnées de contact
|
||||
|
||||
### Catalogues
|
||||
|
||||
- **Composants**, **Pièces**, **Produits** : catalogues avec recherche serveur, tri, pagination et filtres
|
||||
- **Catégories** : système de types avec champs personnalisés configurables et exigences (contraintes de structure)
|
||||
- **Fournisseurs** : gestion des constructeurs/fabricants avec liaison multi-entités
|
||||
|
||||
### Documents et traçabilité
|
||||
|
||||
- **Documents** : upload, prévisualisation PDF/images, stockage sur système de fichiers avec compression PDF automatique
|
||||
- **Journal d'activité** : audit trail complet sur toutes les entités (création, modification, suppression)
|
||||
- **Commentaires** : système de tickets/commentaires sur les fiches avec statut ouvert/résolu
|
||||
|
||||
### Administration
|
||||
|
||||
- **Rôles** : ADMIN, GESTIONNAIRE, VIEWER avec permissions granulaires
|
||||
- **Profils** : gestion des utilisateurs et attribution des rôles
|
||||
- **Notifications** : badge compteur de commentaires ouverts avec polling
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
app/
|
||||
├── pages/ # 36 pages (file-based routing)
|
||||
├── components/ # 57 composants Vue (auto-imported par Nuxt)
|
||||
│ ├── common/ # Composants UI réutilisables (modales, pagination, recherche)
|
||||
│ ├── form/ # Champs de formulaire (email, téléphone)
|
||||
│ ├── layout/ # Navbar principale
|
||||
│ ├── machine/ # Vue détail et création de machines
|
||||
│ │ └── create/ # Wizard de création machine
|
||||
│ ├── model-types/ # Gestion des types/catégories
|
||||
│ └── sites/ # Modales site (création, édition)
|
||||
├── composables/ # 45 composables (logique métier)
|
||||
├── shared/ # Types, utilitaires, validation
|
||||
│ ├── utils/ # Helpers API, champs personnalisés, affichage, erreurs
|
||||
│ ├── validation/ # Validation email, téléphone
|
||||
│ └── model/ # Définitions de structures
|
||||
├── services/ # Service layer (wrappers API spécialisés)
|
||||
├── middleware/ # Middleware d'auth global (session cookie)
|
||||
└── utils/ # Formatage dates, montants, événements
|
||||
```
|
||||
|
||||
## Conventions de code
|
||||
|
||||
### Composables
|
||||
|
||||
Pattern avec injection de dépendances explicite :
|
||||
|
||||
```typescript
|
||||
interface Deps {
|
||||
machineId: Ref<string>
|
||||
onSave: () => void
|
||||
}
|
||||
|
||||
export function useMachineDetail(deps: Deps) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Communication entre composants
|
||||
|
||||
**Props + Events uniquement** — pas de `provide/inject` dans le projet.
|
||||
|
||||
### Appels API
|
||||
|
||||
Le composable `useApi.ts` centralise tous les appels HTTP :
|
||||
- Cookies de session inclus automatiquement (`credentials: 'include'`)
|
||||
- `application/ld+json` pour POST/PUT
|
||||
- `application/merge-patch+json` pour PATCH
|
||||
- Gestion d'erreurs centralisée avec traduction des messages backend en français
|
||||
|
||||
### Styles
|
||||
|
||||
Classes DaisyUI standard :
|
||||
- Input : `input input-bordered input-sm md:input-md`
|
||||
- Select : `select select-bordered select-sm md:select-md`
|
||||
- Button : `btn btn-sm md:btn-md btn-primary`
|
||||
|
||||
## Authentification
|
||||
|
||||
L'application utilise une **authentification par session (cookies)**, pas de JWT.
|
||||
|
||||
Le middleware global `profile.global.ts` vérifie la session à chaque navigation :
|
||||
- Utilisateur non connecté → redirection vers `/profiles`
|
||||
- Route `/admin/*` → accès restreint à `ROLE_ADMIN`
|
||||
|
||||
## Tests
|
||||
|
||||
- **13 tests unitaires** (Vitest + happy-dom) couvrant composables, utils et composants
|
||||
- **3 specs E2E** (Playwright + Chrome) avec setup d'authentification
|
||||
|
||||
## Submodule Git
|
||||
|
||||
Ce repo est un **submodule** du repo principal [Inventory](https://gitea.malio.fr/MALIO-DEV/Inventory).
|
||||
|
||||
Workflow de commit :
|
||||
1. Commiter dans ce repo (frontend) en premier
|
||||
2. Commiter dans le repo principal pour mettre à jour le pointeur submodule
|
||||
3. Pousser les deux repos
|
||||
63
frontend/app/app.vue
Normal file
63
frontend/app/app.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col bg-base-200/40">
|
||||
<!-- Subtle dot pattern background -->
|
||||
<div class="fixed inset-0 -z-10 bg-[radial-gradient(oklch(85%_0.02_260)_1px,transparent_1px)] bg-[size:24px_24px] opacity-40" />
|
||||
|
||||
<AppNavbar
|
||||
@open-settings="displaySettingsOpen = true"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<main class="flex-1">
|
||||
<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
|
||||
</main>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
<ConfirmModal />
|
||||
|
||||
<DisplaySettings
|
||||
:is-open="displaySettingsOpen"
|
||||
@close="displaySettingsOpen = false"
|
||||
@update-settings="handleSettingsUpdate"
|
||||
/>
|
||||
|
||||
<footer class="border-t border-base-300/50 bg-base-100/60 backdrop-blur-sm">
|
||||
<div class="container mx-auto flex items-center justify-between px-6 py-3">
|
||||
<p class="text-xs text-base-content/40 font-medium tracking-wide">
|
||||
© Malio {{ new Date().getFullYear() }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/changelog"
|
||||
class="text-xs text-base-content/40 hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
v{{ appVersion }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { navigateTo, useRuntimeConfig } from '#imports'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
|
||||
const displaySettingsOpen = ref(false)
|
||||
const { ensureSession, logout } = useProfileSession()
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const appVersion = computed(() => (runtimeConfig.public?.appVersion as string) ?? '0.1.0')
|
||||
|
||||
const handleSettingsUpdate = (_settings: unknown) => {
|
||||
// Placeholder for future persistence
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
await navigateTo('/profiles')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await ensureSession()
|
||||
})
|
||||
</script>
|
||||
BIN
frontend/app/assets/LOGO_CARRE_BLANC.png
Normal file
BIN
frontend/app/assets/LOGO_CARRE_BLANC.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
381
frontend/app/assets/app.css
Normal file
381
frontend/app/assets/app.css
Normal file
@@ -0,0 +1,381 @@
|
||||
/* ─── Fonts ─── */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,400&display=swap');
|
||||
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
/* ─── Theme ─── */
|
||||
@plugin "daisyui/theme" {
|
||||
name: "mytheme";
|
||||
default: true;
|
||||
prefersdark: false;
|
||||
color-scheme: light;
|
||||
|
||||
/* Surfaces — warm gray with a hint of blue */
|
||||
--color-base-100: oklch(98.5% 0.004 260);
|
||||
--color-base-200: oklch(95% 0.008 260);
|
||||
--color-base-300: oklch(91% 0.015 260);
|
||||
--color-base-content: oklch(22% 0.025 260);
|
||||
|
||||
/* Primary — Malio blue, slightly richer */
|
||||
--color-primary: oklch(40% 0.16 262);
|
||||
--color-primary-content: oklch(98% 0.005 262);
|
||||
|
||||
/* Secondary — refined lavender */
|
||||
--color-secondary: oklch(72% 0.06 275);
|
||||
--color-secondary-content: oklch(22% 0.03 275);
|
||||
|
||||
/* Accent — warm amber-orange */
|
||||
--color-accent: oklch(72% 0.17 55);
|
||||
--color-accent-content: oklch(20% 0.04 55);
|
||||
|
||||
/* Neutral — deep slate */
|
||||
--color-neutral: oklch(28% 0.04 260);
|
||||
--color-neutral-content: oklch(95% 0.005 260);
|
||||
|
||||
/* Semantic */
|
||||
--color-info: oklch(58% 0.14 255);
|
||||
--color-info-content: oklch(98% 0.005 255);
|
||||
--color-success: oklch(62% 0.19 150);
|
||||
--color-success-content: oklch(98% 0.005 150);
|
||||
--color-warning: oklch(78% 0.15 70);
|
||||
--color-warning-content: oklch(22% 0.05 70);
|
||||
--color-error: oklch(58% 0.22 25);
|
||||
--color-error-content: oklch(98% 0.005 25);
|
||||
|
||||
/* Geometry */
|
||||
--radius-selector: 0.75rem;
|
||||
--radius-field: 0.375rem;
|
||||
--radius-box: 0.625rem;
|
||||
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "mytheme-dark";
|
||||
default: false;
|
||||
prefersdark: true;
|
||||
color-scheme: dark;
|
||||
|
||||
/* Surfaces — dark blue-gray */
|
||||
--color-base-100: oklch(22% 0.015 260);
|
||||
--color-base-200: oklch(18% 0.012 260);
|
||||
--color-base-300: oklch(28% 0.018 260);
|
||||
--color-base-content: oklch(92% 0.005 260);
|
||||
|
||||
/* Primary — Malio blue, brighter for dark */
|
||||
--color-primary: oklch(55% 0.18 262);
|
||||
--color-primary-content: oklch(98% 0.005 262);
|
||||
|
||||
/* Secondary — refined lavender */
|
||||
--color-secondary: oklch(72% 0.06 275);
|
||||
--color-secondary-content: oklch(22% 0.03 275);
|
||||
|
||||
/* Accent — warm amber-orange */
|
||||
--color-accent: oklch(72% 0.17 55);
|
||||
--color-accent-content: oklch(20% 0.04 55);
|
||||
|
||||
/* Neutral — lighter slate for dark mode */
|
||||
--color-neutral: oklch(75% 0.02 260);
|
||||
--color-neutral-content: oklch(18% 0.01 260);
|
||||
|
||||
/* Semantic */
|
||||
--color-info: oklch(62% 0.14 255);
|
||||
--color-info-content: oklch(98% 0.005 255);
|
||||
--color-success: oklch(65% 0.19 150);
|
||||
--color-success-content: oklch(98% 0.005 150);
|
||||
--color-warning: oklch(78% 0.15 70);
|
||||
--color-warning-content: oklch(22% 0.05 70);
|
||||
--color-error: oklch(62% 0.22 25);
|
||||
--color-error-content: oklch(98% 0.005 25);
|
||||
|
||||
/* Geometry — same as light */
|
||||
--radius-selector: 0.75rem;
|
||||
--radius-field: 0.375rem;
|
||||
--radius-box: 0.625rem;
|
||||
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* ─── Typography ─── */
|
||||
:root {
|
||||
--font-heading: 'Outfit', system-ui, sans-serif;
|
||||
--font-body: 'DM Sans', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6,
|
||||
.card-title,
|
||||
.stat-value,
|
||||
.text-2xl,
|
||||
.text-3xl,
|
||||
.text-4xl {
|
||||
font-family: var(--font-heading);
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
/* ─── Density variables ─── */
|
||||
:root {
|
||||
--spacing-xs: 0.5rem;
|
||||
--spacing-sm: 0.75rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
}
|
||||
|
||||
.density-compact {
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 0.75rem;
|
||||
--spacing-lg: 1rem;
|
||||
--spacing-xl: 1.25rem;
|
||||
}
|
||||
|
||||
.density-comfortable {
|
||||
--spacing-xs: 0.5rem;
|
||||
--spacing-sm: 0.75rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
}
|
||||
|
||||
.density-spacious {
|
||||
--spacing-xs: 0.75rem;
|
||||
--spacing-sm: 1rem;
|
||||
--spacing-md: 1.5rem;
|
||||
--spacing-lg: 2rem;
|
||||
--spacing-xl: 3rem;
|
||||
}
|
||||
|
||||
/* ─── High contrast mode ─── */
|
||||
.contrast-high .btn { @apply border-2; }
|
||||
.contrast-high .input { @apply border-2; }
|
||||
.contrast-high .select { @apply border-2; }
|
||||
.contrast-high .textarea { @apply border-2; }
|
||||
.contrast-high .modal-box { @apply border-2 border-base-content; }
|
||||
|
||||
/* ─── Accessibility ─── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: 2px solid oklch(40% 0.16 262);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ─── Cards ─── */
|
||||
.card {
|
||||
border: 1px solid oklch(91% 0.015 260 / 0.6);
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.site-card {
|
||||
background-color: oklch(100% 0 0);
|
||||
}
|
||||
|
||||
[data-theme="mytheme-dark"] .site-card {
|
||||
background-color: oklch(24% 0.015 260);
|
||||
}
|
||||
|
||||
[data-theme="mytheme-dark"] .card {
|
||||
border-color: oklch(30% 0.02 260 / 0.6);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow:
|
||||
0 4px 6px -1px oklch(22% 0.025 260 / 0.06),
|
||||
0 2px 4px -2px oklch(22% 0.025 260 / 0.04);
|
||||
}
|
||||
|
||||
/* ─── Navbar glass effect ─── */
|
||||
.navbar-glass {
|
||||
background: oklch(98.5% 0.004 260 / 0.82);
|
||||
backdrop-filter: blur(12px) saturate(1.5);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(1.5);
|
||||
border-bottom: 1px solid oklch(91% 0.015 260 / 0.5);
|
||||
}
|
||||
|
||||
[data-theme="mytheme-dark"] .navbar-glass {
|
||||
background: oklch(22% 0.015 260 / 0.85);
|
||||
border-bottom-color: oklch(30% 0.02 260 / 0.5);
|
||||
}
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.btn {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-circle {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.btn-circle:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.btn-circle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ─── Inputs ─── */
|
||||
.input, .select, .textarea {
|
||||
font-family: var(--font-body);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.input:focus, .select:focus, .textarea:focus {
|
||||
box-shadow: 0 0 0 3px oklch(40% 0.16 262 / 0.1);
|
||||
}
|
||||
|
||||
/* ─── Tables ─── */
|
||||
.table thead th {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.7rem;
|
||||
color: oklch(45% 0.03 260);
|
||||
}
|
||||
|
||||
.table tbody tr {
|
||||
transition: background-color 0.1s ease;
|
||||
}
|
||||
|
||||
/* ─── Badges ─── */
|
||||
.badge {
|
||||
font-family: var(--font-heading);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
/* ─── Stats ─── */
|
||||
.stat-title {
|
||||
font-family: var(--font-body);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ─── Modals ─── */
|
||||
.modal {
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.modal-box {
|
||||
font-family: var(--font-body);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid oklch(91% 0.015 260 / 0.5);
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from { opacity: 0; transform: translateY(0.5rem); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.modal.modal-open .modal-box {
|
||||
animation: modalSlideUp 0.25s ease-out;
|
||||
}
|
||||
|
||||
/* ─── Page transitions ─── */
|
||||
.page-enter-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.page-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ─── Scrollbar styling ─── */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(75% 0.02 260);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(60% 0.03 260);
|
||||
}
|
||||
|
||||
/* ─── Readability ─── */
|
||||
.text-sm { line-height: 1.5; }
|
||||
.text-xs { line-height: 1.4; }
|
||||
|
||||
/* ─── Adaptive spacing ─── */
|
||||
.p-1 { padding: var(--spacing-xs); }
|
||||
.p-2 { padding: var(--spacing-sm); }
|
||||
.p-3 { padding: var(--spacing-md); }
|
||||
.p-4 { padding: var(--spacing-lg); }
|
||||
.p-5 { padding: var(--spacing-xl); }
|
||||
|
||||
.m-1 { margin: var(--spacing-xs); }
|
||||
.m-2 { margin: var(--spacing-sm); }
|
||||
.m-3 { margin: var(--spacing-md); }
|
||||
.m-4 { margin: var(--spacing-lg); }
|
||||
.m-5 { margin: var(--spacing-xl); }
|
||||
|
||||
.gap-1 { gap: var(--spacing-xs); }
|
||||
.gap-2 { gap: var(--spacing-sm); }
|
||||
.gap-3 { gap: var(--spacing-md); }
|
||||
.gap-4 { gap: var(--spacing-lg); }
|
||||
.gap-5 { gap: var(--spacing-xl); }
|
||||
|
||||
@layer components {
|
||||
.form-control .label {
|
||||
@apply mb-2;
|
||||
padding-bottom: 0;
|
||||
margin-right: 15px;
|
||||
}
|
||||
.form-control .label + * {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
label + input,
|
||||
label + select,
|
||||
label + textarea,
|
||||
label + .input,
|
||||
label + .select,
|
||||
label + .textarea {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
}
|
||||
55
frontend/app/components/CommentDocumentList.vue
Normal file
55
frontend/app/components/CommentDocumentList.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="space-y-1 mt-2">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="w-4 h-4 flex-shrink-0"
|
||||
:class="documentIcon(doc).colorClass"
|
||||
/>
|
||||
<span class="truncate">{{ doc.name || doc.filename }}</span>
|
||||
<span class="text-base-content/40 flex-shrink-0">{{ formatSize(doc.size) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter' : 'Aperçu non disponible'"
|
||||
@click="openPreview(doc)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(doc)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CommentDocument } from '~/composables/useComments'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import { formatSize, documentIcon, downloadDocument } from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps<{
|
||||
documents: CommentDocument[]
|
||||
}>()
|
||||
|
||||
const openPreview = (doc: CommentDocument) => {
|
||||
if (!canPreviewDocument(doc)) return
|
||||
// Open file URL in new tab for preview
|
||||
if (doc.fileUrl) {
|
||||
window.open(doc.fileUrl, '_blank')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
270
frontend/app/components/CommentSection.vue
Normal file
270
frontend/app/components/CommentSection.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||
<IconLucideMessageSquare class="w-5 h-5" />
|
||||
Commentaires
|
||||
<span v-if="openComments.length" class="badge badge-warning badge-sm">
|
||||
{{ openComments.length }}
|
||||
</span>
|
||||
</h3>
|
||||
<button
|
||||
v-if="showResolved && resolvedComments.length"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="showResolvedList = !showResolvedList"
|
||||
>
|
||||
{{ showResolvedList ? 'Masquer résolus' : `Voir résolus (${resolvedComments.length})` }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<textarea
|
||||
v-model="newContent"
|
||||
class="textarea textarea-bordered flex-1 text-sm"
|
||||
rows="2"
|
||||
placeholder="Ajouter un commentaire..."
|
||||
:disabled="submitting"
|
||||
@keydown.ctrl.enter="handleSubmit"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 self-end">
|
||||
<label
|
||||
class="btn btn-ghost btn-sm btn-square tooltip tooltip-left"
|
||||
data-tip="Joindre des fichiers"
|
||||
>
|
||||
<IconLucidePaperclip class="w-4 h-4" />
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
multiple
|
||||
class="hidden"
|
||||
@change="handleFilesSelected"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm btn-square"
|
||||
:disabled="!newContent.trim() || submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<span v-if="submitting" class="loading loading-spinner loading-xs" />
|
||||
<IconLucideSend v-else class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Selected files preview -->
|
||||
<div v-if="selectedFiles.length" class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="(file, i) in selectedFiles"
|
||||
:key="i"
|
||||
class="badge badge-sm badge-outline gap-1"
|
||||
>
|
||||
<IconLucideFile class="w-3 h-3" />
|
||||
{{ file.name }}
|
||||
<button type="button" class="ml-1" @click="removeFile(i)">
|
||||
<IconLucideX class="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des commentaires ouverts -->
|
||||
<div v-if="loadingComments" class="flex justify-center py-4">
|
||||
<span class="loading loading-spinner loading-sm" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="openComments.length === 0" class="text-sm text-base-content/50 py-2">
|
||||
Aucun commentaire ouvert.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="comment in openComments"
|
||||
:key="comment.id"
|
||||
class="bg-base-200 rounded-lg p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||
<!-- Documents attachés -->
|
||||
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||
<span>
|
||||
{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}
|
||||
</span>
|
||||
<div v-if="canEdit" class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-xs gap-1"
|
||||
:disabled="loading"
|
||||
@click="handleResolve(comment.id)"
|
||||
>
|
||||
<IconLucideCheck class="w-3 h-3" />
|
||||
Résoudre
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="loading"
|
||||
@click="handleDelete(comment.id)"
|
||||
>
|
||||
<IconLucideTrash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commentaires résolus -->
|
||||
<div v-if="showResolvedList && resolvedComments.length" class="space-y-2">
|
||||
<div class="divider text-xs text-base-content/40">
|
||||
Résolus
|
||||
</div>
|
||||
<div
|
||||
v-for="comment in resolvedComments"
|
||||
:key="comment.id"
|
||||
class="bg-base-200/50 rounded-lg p-3 opacity-60 space-y-1"
|
||||
>
|
||||
<p class="text-sm whitespace-pre-wrap">{{ comment.content }}</p>
|
||||
<!-- Documents attachés (résolus) -->
|
||||
<CommentDocumentList :documents="getDocuments(comment)" />
|
||||
<div class="flex items-center justify-between text-xs text-base-content/50">
|
||||
<span>{{ comment.authorName }} — {{ formatCommentDate(comment.createdAt) }}</span>
|
||||
<span v-if="comment.resolvedByName">
|
||||
Résolu par {{ comment.resolvedByName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useComments, type Comment, type CommentDocument } from '~/composables/useComments'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import CommentDocumentList from '~/components/CommentDocumentList.vue'
|
||||
import IconLucideMessageSquare from '~icons/lucide/message-square'
|
||||
import IconLucideSend from '~icons/lucide/send'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||
import IconLucidePaperclip from '~icons/lucide/paperclip'
|
||||
import IconLucideFile from '~icons/lucide/file'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: string
|
||||
entityId: string
|
||||
entityName?: string
|
||||
showResolved?: boolean
|
||||
}>()
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const {
|
||||
loading,
|
||||
fetchComments,
|
||||
createComment,
|
||||
resolveComment,
|
||||
deleteComment,
|
||||
} = useComments()
|
||||
|
||||
const comments = ref<Comment[]>([])
|
||||
const newContent = ref('')
|
||||
const submitting = ref(false)
|
||||
const loadingComments = ref(false)
|
||||
const showResolvedList = ref(false)
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const getDocuments = (comment: Comment): CommentDocument[] =>
|
||||
comment.documents?.filter((d): d is CommentDocument => typeof d === 'object' && d !== null && 'id' in d) ?? []
|
||||
|
||||
const openComments = computed(() =>
|
||||
comments.value.filter(c => c.status === 'open'),
|
||||
)
|
||||
|
||||
const resolvedComments = computed(() =>
|
||||
comments.value.filter(c => c.status === 'resolved'),
|
||||
)
|
||||
|
||||
const formatCommentDate = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
const handleFilesSelected = (e: Event) => {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
selectedFiles.value.push(...Array.from(input.files))
|
||||
}
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
selectedFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const loadComments = async () => {
|
||||
loadingComments.value = true
|
||||
const [openResult, resolvedResult] = await Promise.all([
|
||||
fetchComments(props.entityType, props.entityId, 'open'),
|
||||
props.showResolved
|
||||
? fetchComments(props.entityType, props.entityId, 'resolved')
|
||||
: Promise.resolve({ success: true, data: [] as Comment[] }),
|
||||
])
|
||||
const open = openResult.success ? (openResult.data ?? []) : []
|
||||
const resolved = resolvedResult.success ? (resolvedResult.data ?? []) : []
|
||||
comments.value = [...open, ...resolved]
|
||||
loadingComments.value = false
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const content = newContent.value.trim()
|
||||
if (!content) return
|
||||
submitting.value = true
|
||||
const result = await createComment(
|
||||
props.entityType,
|
||||
props.entityId,
|
||||
content,
|
||||
props.entityName,
|
||||
selectedFiles.value.length > 0 ? selectedFiles.value : undefined,
|
||||
)
|
||||
submitting.value = false
|
||||
if (result.success) {
|
||||
newContent.value = ''
|
||||
selectedFiles.value = []
|
||||
await loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
const handleResolve = async (commentId: string) => {
|
||||
const result = await resolveComment(commentId)
|
||||
if (result.success) {
|
||||
await loadComments()
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (commentId: string) => {
|
||||
const result = await deleteComment(commentId)
|
||||
if (result.success) {
|
||||
comments.value = comments.value.filter(c => c.id !== commentId)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (props.entityId) {
|
||||
loadComments()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
47
frontend/app/components/ComponentHierarchy.vue
Normal file
47
frontend/app/components/ComponentHierarchy.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Root Components -->
|
||||
<div v-for="component in components" :key="component.id" class="border border-gray-200 rounded-lg p-4">
|
||||
<ComponentItem
|
||||
:component="component"
|
||||
:is-edit-mode="isEditMode"
|
||||
:show-delete="showDelete"
|
||||
:collapse-all="collapseAll"
|
||||
:toggle-token="toggleToken"
|
||||
@update="$emit('update', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
@delete="$emit('delete')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ComponentItem from './ComponentItem.vue'
|
||||
|
||||
defineProps({
|
||||
components: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isEditMode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDelete: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
collapseAll: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
toggleToken: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
</script>
|
||||
465
frontend/app/components/ComponentItem.vue
Normal file
465
frontend/app/components/ComponentItem.vue
Normal file
@@ -0,0 +1,465 @@
|
||||
<template>
|
||||
<div>
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="componentDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- Component Header -->
|
||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg cursor-pointer" @click="toggleCollapse">
|
||||
<IconLucideChevronRight
|
||||
class="w-4 h-4 shrink-0 transition-transform text-base-content/50"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<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">
|
||||
{{ component.name }}
|
||||
</h3>
|
||||
<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>
|
||||
<div v-if="componentConstructeursDisplay.length || displayProductName" class="flex flex-wrap gap-1.5 mt-1">
|
||||
<span
|
||||
v-for="constructeur in componentConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="opacity-70">({{ supplierReferenceMap.get(constructeur.id) }})</span>
|
||||
</span>
|
||||
<span v-if="displayProductName" class="badge badge-info badge-xs">
|
||||
{{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="showDelete"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||
title="Supprimer ce composant"
|
||||
@click.stop="$emit('delete')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expanded content -->
|
||||
<div v-show="!isCollapsed" 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">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Nom</span></label>
|
||||
<input v-model="component.name" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Référence</span></label>
|
||||
<input v-model="component.reference" type="text" class="input input-bordered input-sm" @blur="updateComponent">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Prix</span></label>
|
||||
<input v-model="component.prix" type="number" step="0.01" class="input input-bordered input-sm" @blur="updateComponent">
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs font-medium text-base-content/60">Fournisseur</span></label>
|
||||
<ConstructeurSelect
|
||||
class="w-full"
|
||||
:model-value="componentConstructeurIds"
|
||||
:initial-options="componentConstructeursDisplay"
|
||||
@update:model-value="handleConstructeurChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Read-only info -->
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-4 gap-x-6 gap-y-3 text-sm">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/40 mb-0.5">Nom</p>
|
||||
<p class="text-base-content">{{ component.name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/40 mb-0.5">Référence</p>
|
||||
<p class="text-base-content">{{ component.reference || '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/40 mb-0.5">Prix</p>
|
||||
<p class="text-base-content">{{ component.prix ? `${component.prix} €` : '—' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/40 mb-0.5">Fournisseur</p>
|
||||
<div v-if="componentConstructeursDisplay.length">
|
||||
<p
|
||||
v-for="constructeur in componentConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="text-base-content"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm text-base-content/60">
|
||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||
</span>
|
||||
<span v-if="formatConstructeurContact(constructeur)" class="text-xs text-base-content/50 block">
|
||||
{{ formatConstructeurContact(constructeur) }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-base-content">—</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product -->
|
||||
<div v-if="displayProduct" class="rounded-lg border border-base-200 bg-base-100 p-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs text-base-content/40">Produit catalogue</p>
|
||||
<p class="text-sm font-semibold text-base-content">{{ displayProductName }}</p>
|
||||
<p
|
||||
v-for="info in productInfoRows"
|
||||
:key="info.label"
|
||||
class="text-xs text-base-content/60"
|
||||
>
|
||||
{{ info.label }} : {{ info.value }}
|
||||
</p>
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-if="component.product?.id"
|
||||
:to="`/product/${component.product.id}`"
|
||||
class="btn btn-ghost btn-xs shrink-0"
|
||||
>
|
||||
Voir le produit
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<!-- Product documents -->
|
||||
<div v-if="productDocuments.length" class="mt-3 pt-3 border-t border-base-200 space-y-2">
|
||||
<p class="text-xs font-medium text-base-content/50">Documents du produit</p>
|
||||
<div
|
||||
v-for="document in productDocuments"
|
||||
:key="document.id || document.path || document.name"
|
||||
class="flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="flex-shrink-0 overflow-hidden rounded border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-7">
|
||||
<img
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(document)"
|
||||
:src="documentPreviewSrc(document)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(document).component"
|
||||
class="h-4 w-4"
|
||||
:class="documentIcon(document).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<span class="truncate text-base-content">{{ document.name }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(document)"
|
||||
@click="openPreview(document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldDisplay
|
||||
:fields="displayedCustomFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
:columns="2"
|
||||
@field-blur="updateComponentCustomField"
|
||||
/>
|
||||
|
||||
<!-- Documents -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Documents</p>
|
||||
<span v-if="isEditMode && selectedFiles.length" class="badge badge-outline badge-xs">
|
||||
{{ selectedFiles.length }} fichier{{ selectedFiles.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
||||
Chargement...
|
||||
</p>
|
||||
|
||||
<DocumentUpload
|
||||
v-if="isEditMode"
|
||||
v-model="selectedFiles"
|
||||
title="Déposer des fichiers pour ce composant"
|
||||
subtitle="Formats acceptés : PDF, images, documents..."
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
|
||||
<DocumentListInline
|
||||
:documents="componentDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à ce composant."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Component Pieces (real MachinePieceLinks) -->
|
||||
<div v-if="linkedPieces.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Pièces du composant
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<PieceItem
|
||||
v-for="piece in linkedPieces"
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode"
|
||||
@update="updatePiece"
|
||||
@edit="editPiece"
|
||||
@custom-field-update="updatePieceCustomField"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Structure pieces (read-only, from composant definition) -->
|
||||
<div v-if="structurePieces.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Pièces incluses par défaut
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<PieceItem
|
||||
v-for="piece in structurePieces"
|
||||
:key="piece.id"
|
||||
:piece="piece"
|
||||
:is-edit-mode="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sub Components -->
|
||||
<div v-if="childComponents.length > 0" class="space-y-2">
|
||||
<p class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">
|
||||
Sous-composants
|
||||
</p>
|
||||
<div class="space-y-2 pl-4 border-l-2 border-base-200">
|
||||
<ComponentItem
|
||||
v-for="subComponent in childComponents"
|
||||
:key="subComponent.id"
|
||||
:component="subComponent"
|
||||
:is-edit-mode="isEditMode"
|
||||
:collapse-all="collapseAll"
|
||||
:toggle-token="toggleToken"
|
||||
@update="$emit('update', $event)"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@custom-field-update="$emit('custom-field-update', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import PieceItem from './PieceItem.vue'
|
||||
import DocumentUpload from './DocumentUpload.vue'
|
||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
|
||||
const props = defineProps({
|
||||
component: { type: Object, required: true },
|
||||
isEditMode: { type: Boolean, default: false },
|
||||
showDelete: { type: Boolean, default: false },
|
||||
collapseAll: { type: Boolean, default: true },
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit-piece', 'custom-field-update', 'delete'])
|
||||
|
||||
// --- Shared composables ---
|
||||
const {
|
||||
documents: componentDocuments,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
openPreview,
|
||||
closePreview,
|
||||
ensureDocumentsLoaded,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
} = useEntityDocuments({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
const {
|
||||
displayProduct,
|
||||
displayProductName,
|
||||
productInfoRows,
|
||||
productDocuments,
|
||||
} = useEntityProductDisplay({ entity: () => props.component })
|
||||
|
||||
const {
|
||||
displayedCustomFields,
|
||||
updateCustomField: updateComponentCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.component, entityType: 'composant' })
|
||||
|
||||
// --- Document edit modal ---
|
||||
const editingDocument = ref(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const openEditModal = (doc) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
}
|
||||
const handleDocumentUpdated = async (data) => {
|
||||
if (!editingDocument.value?.id) return
|
||||
await editDocument(editingDocument.value.id, data)
|
||||
editModalVisible.value = false
|
||||
editingDocument.value = null
|
||||
}
|
||||
|
||||
// --- Collapse state ---
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) ensureDocumentsLoaded()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) ensureDocumentsLoaded()
|
||||
}
|
||||
|
||||
// --- Child components ---
|
||||
const childComponents = computed(() => {
|
||||
const list = props.component.subcomponents || props.component.subComponents || []
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
|
||||
// --- Pieces split: real links vs structure definitions ---
|
||||
const allPieces = computed(() => {
|
||||
const list = props.component.pieces
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
const linkedPieces = computed(() => allPieces.value.filter((p) => !p._structurePiece))
|
||||
const structurePieces = computed(() => allPieces.value.filter((p) => p._structurePiece))
|
||||
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const componentConstructeurLinks = computed(() =>
|
||||
parseConstructeurLinksFromApi(
|
||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||
),
|
||||
)
|
||||
|
||||
const supplierReferenceMap = computed(() => {
|
||||
const map = new Map()
|
||||
componentConstructeurLinks.value.forEach(l => {
|
||||
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const componentConstructeurIds = computed(() =>
|
||||
componentConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
|
||||
)
|
||||
|
||||
const componentConstructeursDisplay = computed(() => {
|
||||
// Extract nested constructeur objects from link entries
|
||||
const linkConstructeurs = componentConstructeurLinks.value
|
||||
.filter(l => l.constructeur && l.constructeur.id)
|
||||
.map(l => l.constructeur)
|
||||
return resolveConstructeurs(
|
||||
componentConstructeurIds.value,
|
||||
linkConstructeurs,
|
||||
constructeurs.value,
|
||||
)
|
||||
})
|
||||
|
||||
const formatConstructeurContact = (constructeur) =>
|
||||
formatConstructeurContactSummary(constructeur)
|
||||
|
||||
const handleConstructeurChange = async (value) => {
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
props.component.constructeurIds = [...ids]
|
||||
props.component.constructeurId = null
|
||||
props.component.constructeur = null
|
||||
props.component.constructeurs = resolveConstructeurs(
|
||||
ids,
|
||||
constructeurs.value,
|
||||
Array.isArray(props.component.constructeurs) ? props.component.constructeurs : [],
|
||||
)
|
||||
await updateComponent()
|
||||
}
|
||||
|
||||
// --- Update / Event forwarding ---
|
||||
const updateComponent = () => {
|
||||
emit('update', {
|
||||
...props.component,
|
||||
constructeurIds: componentConstructeurIds.value,
|
||||
})
|
||||
}
|
||||
|
||||
const updatePiece = (updatedPiece) => {
|
||||
emit('edit-piece', updatedPiece)
|
||||
}
|
||||
|
||||
const editPiece = (piece) => {
|
||||
emit('edit-piece', piece)
|
||||
}
|
||||
|
||||
const updatePieceCustomField = (fieldUpdate) => {
|
||||
emit('custom-field-update', fieldUpdate)
|
||||
emit('edit-piece', { ...fieldUpdate, type: 'custom-field-update' })
|
||||
}
|
||||
</script>
|
||||
269
frontend/app/components/ComponentModelStructureEditor.vue
Normal file
269
frontend/app/components/ComponentModelStructureEditor.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<StructureNodeEditor
|
||||
:node="localStructure"
|
||||
:depth="0"
|
||||
:component-types="availableComponentTypes"
|
||||
:piece-types="availablePieceTypes"
|
||||
:product-types="availableProductTypes"
|
||||
:lock-type="lockRootType"
|
||||
:locked-type-label="displayedRootTypeLabel"
|
||||
:allow-subcomponents="allowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
is-root
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, computed, onMounted, ref } from 'vue'
|
||||
import StructureNodeEditor from '~/components/StructureNodeEditor.vue'
|
||||
import {
|
||||
defaultStructure,
|
||||
hydrateStructureForEditor,
|
||||
cloneStructure,
|
||||
} from '~/shared/modelUtils'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
|
||||
defineOptions({ name: 'ComponentModelStructureEditor' })
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => defaultStructure(),
|
||||
},
|
||||
rootTypeId: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
rootTypeLabel: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
lockRootType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowSubcomponents: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
maxSubcomponentDepth: {
|
||||
type: Number,
|
||||
default: Infinity,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const localStructure = reactive<ComponentModelStructure>(hydrateStructureForEditor(props.modelValue))
|
||||
const previousLockedLabel = ref(props.rootTypeLabel || '')
|
||||
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
|
||||
const availablePieceTypes = computed(() => pieceTypes.value ?? [])
|
||||
const availableComponentTypes = computed(() => componentTypes.value ?? [])
|
||||
const availableProductTypes = computed(() => productTypes.value ?? [])
|
||||
const allowSubcomponents = computed(() => props.allowSubcomponents !== false)
|
||||
const maxSubcomponentDepth = computed(() =>
|
||||
typeof props.maxSubcomponentDepth === 'number' ? props.maxSubcomponentDepth : Infinity,
|
||||
)
|
||||
|
||||
const fallbackRootTypeLabel = computed(() => {
|
||||
if (!props.rootTypeId) {
|
||||
return ''
|
||||
}
|
||||
const match = availableComponentTypes.value.find((type) => type?.id === props.rootTypeId)
|
||||
return match?.name || ''
|
||||
})
|
||||
|
||||
const displayedRootTypeLabel = computed(() => props.rootTypeLabel || fallbackRootTypeLabel.value)
|
||||
|
||||
const formatOptionsText = (field: Record<string, any>) => {
|
||||
if (typeof field?.optionsText === 'string') {
|
||||
return field.optionsText
|
||||
}
|
||||
if (Array.isArray(field?.options)) {
|
||||
return field.options.join('\n')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizeLineEndings = (text: string) =>
|
||||
text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
const parseOptionsFromText = (text: string) => {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((option) => option.trim())
|
||||
.filter((option) => option.length > 0)
|
||||
}
|
||||
|
||||
const applyCustomFieldOptions = (node: Record<string, any> | null | undefined) => {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
if (Array.isArray(node.customFields)) {
|
||||
node.customFields = node.customFields.map((field: Record<string, any>) => {
|
||||
if (!field || typeof field !== 'object') {
|
||||
return field
|
||||
}
|
||||
|
||||
const next = { ...field }
|
||||
if (next.type === 'select') {
|
||||
const baseText = normalizeLineEndings(formatOptionsText(next))
|
||||
if (next.optionsText !== baseText) {
|
||||
next.optionsText = baseText
|
||||
}
|
||||
const parsedOptions = parseOptionsFromText(next.optionsText || '')
|
||||
if (parsedOptions.length > 0) {
|
||||
next.options = parsedOptions
|
||||
} else {
|
||||
delete next.options
|
||||
}
|
||||
} else {
|
||||
if (next.options !== undefined) {
|
||||
delete next.options
|
||||
}
|
||||
if (next.optionsText !== undefined && next.optionsText !== '') {
|
||||
next.optionsText = ''
|
||||
}
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(node.subcomponents)) {
|
||||
node.subcomponents = node.subcomponents.map((sub: Record<string, any>) => {
|
||||
if (!sub || typeof sub !== 'object') {
|
||||
return sub
|
||||
}
|
||||
const copy = { ...sub }
|
||||
applyCustomFieldOptions(copy)
|
||||
return copy
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const prepareStructureForEmit = (structure: any) => {
|
||||
const clone = cloneStructure(structure)
|
||||
applyCustomFieldOptions(clone as Record<string, any>)
|
||||
return clone
|
||||
}
|
||||
|
||||
const syncRootType = () => {
|
||||
if (!props.lockRootType) {
|
||||
previousLockedLabel.value = props.rootTypeLabel || ''
|
||||
return
|
||||
}
|
||||
|
||||
const newTypeId = props.rootTypeId || ''
|
||||
const newLabel = displayedRootTypeLabel.value
|
||||
|
||||
localStructure.typeComposantId = newTypeId
|
||||
localStructure.typeComposantLabel = newLabel
|
||||
|
||||
const match = availableComponentTypes.value.find((type) => type?.id === newTypeId)
|
||||
if (match?.code) {
|
||||
localStructure.familyCode = match.code
|
||||
}
|
||||
|
||||
const previousLabel = previousLockedLabel.value
|
||||
if (!localStructure.alias || localStructure.alias === previousLabel || localStructure.alias === '') {
|
||||
localStructure.alias = newLabel || localStructure.alias
|
||||
}
|
||||
|
||||
previousLockedLabel.value = newLabel
|
||||
}
|
||||
|
||||
let lastEmitted = JSON.stringify(prepareStructureForEmit(props.modelValue))
|
||||
|
||||
const syncFromProps = (value: any) => {
|
||||
const normalizedIncoming = prepareStructureForEmit(value)
|
||||
const incomingSerialized = JSON.stringify(normalizedIncoming)
|
||||
if (incomingSerialized === lastEmitted) {
|
||||
return
|
||||
}
|
||||
const hydrated = hydrateStructureForEditor(value)
|
||||
localStructure.customFields = hydrated.customFields
|
||||
localStructure.pieces = hydrated.pieces
|
||||
localStructure.products = hydrated.products
|
||||
localStructure.subcomponents = hydrated.subcomponents
|
||||
localStructure.typeComposantId = hydrated.typeComposantId
|
||||
localStructure.typeComposantLabel = hydrated.typeComposantLabel
|
||||
localStructure.modelId = hydrated.modelId
|
||||
localStructure.familyCode = hydrated.familyCode
|
||||
localStructure.alias = hydrated.alias
|
||||
lastEmitted = incomingSerialized
|
||||
syncRootType()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
syncFromProps(value)
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.rootTypeId, props.rootTypeLabel, props.lockRootType],
|
||||
() => {
|
||||
syncRootType()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
availableComponentTypes,
|
||||
() => {
|
||||
syncRootType()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
localStructure,
|
||||
(value) => {
|
||||
const payload = prepareStructureForEmit(value)
|
||||
const serialized = JSON.stringify(payload)
|
||||
if (serialized !== lastEmitted) {
|
||||
lastEmitted = serialized
|
||||
emit('update:modelValue', payload)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const loaders: Promise<unknown>[] = []
|
||||
if (!availablePieceTypes.value.length) {
|
||||
loaders.push(loadPieceTypes())
|
||||
}
|
||||
if (!availableComponentTypes.value.length) {
|
||||
loaders.push(loadComponentTypes())
|
||||
}
|
||||
if (!availableProductTypes.value.length) {
|
||||
loaders.push(loadProductTypes())
|
||||
}
|
||||
if (loaders.length) {
|
||||
await Promise.allSettled(loaders)
|
||||
}
|
||||
syncRootType()
|
||||
})
|
||||
|
||||
watch(
|
||||
allowSubcomponents,
|
||||
(allowed) => {
|
||||
if (!allowed && Array.isArray(localStructure.subcomponents) && localStructure.subcomponents.length) {
|
||||
localStructure.subcomponents = []
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
262
frontend/app/components/ComponentStructureAssignmentNode.vue
Normal file
262
frontend/app/components/ComponentStructureAssignmentNode.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<section v-if="!isRoot" class="rounded-lg border border-base-200 bg-base-100 p-4 space-y-3">
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
{{ requirementLabel }}
|
||||
</h4>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ requirementDescription }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs">Sélectionner un composant</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
:model-value="assignment.selectedComponentId || ''"
|
||||
:options="componentOptions"
|
||||
:loading="componentsLoading || componentLoadingByPath[assignment.path]"
|
||||
size="sm"
|
||||
placeholder="Rechercher un composant..."
|
||||
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
|
||||
:option-label="componentOptionLabel"
|
||||
:option-description="componentOptionDescription"
|
||||
server-search
|
||||
@search="fetchComponentOptions"
|
||||
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="assignment.pieces.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
{{ isRoot ? 'Pièces requises par le squelette' : 'Pièces associées à ce sous-composant' }}
|
||||
</h4>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Sélectionnez les pièces concrètes à associer pour chaque emplacement.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-for="pieceAssignment in assignment.pieces"
|
||||
:key="pieceAssignment.path"
|
||||
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content">
|
||||
{{ describePieceRequirement(pieceAssignment) }}
|
||||
</p>
|
||||
<p v-if="!getPieceOptions(pieceAssignment).length" class="text-[11px] text-error">
|
||||
Aucune pièce disponible pour cette famille.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchSelect
|
||||
:model-value="pieceAssignment.selectedPieceId || ''"
|
||||
:options="getPieceOptions(pieceAssignment)"
|
||||
:loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
|
||||
size="xs"
|
||||
placeholder="Rechercher une pièce..."
|
||||
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
||||
:option-label="pieceOptionLabel"
|
||||
:option-description="pieceOptionDescription"
|
||||
server-search
|
||||
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
|
||||
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="assignment.products.length" class="rounded-lg border border-dashed border-base-300 bg-base-200/40 p-4 space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
{{ isRoot ? 'Produits requis par le squelette' : 'Produits associés à ce sous-composant' }}
|
||||
</h4>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Sélectionnez les produits catalogue à lier sur chaque position définie.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-for="productAssignment in assignment.products"
|
||||
:key="productAssignment.path"
|
||||
class="rounded-md border border-base-200 bg-base-100 p-3 space-y-2"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content">
|
||||
{{ describeProductRequirement(productAssignment) }}
|
||||
</p>
|
||||
<p v-if="!getProductOptions(productAssignment).length" class="text-[11px] text-error">
|
||||
Aucun produit disponible pour cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchSelect
|
||||
:model-value="productAssignment.selectedProductId || ''"
|
||||
:options="getProductOptions(productAssignment)"
|
||||
:loading="productsLoading || productLoadingByPath[productAssignment.path]"
|
||||
size="xs"
|
||||
placeholder="Rechercher un produit..."
|
||||
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||
:option-label="productOptionLabel"
|
||||
:option-description="productOptionDescription"
|
||||
server-search
|
||||
@search="(term) => fetchProductOptions(productAssignment, term)"
|
||||
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="assignment.subcomponents.length" class="space-y-4">
|
||||
<header class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-base-content">
|
||||
{{ isRoot ? 'Sous-composants définis par le squelette' : 'Sous-composants imbriqués' }}
|
||||
</h4>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Choisissez un composant existant pour chaque sous-niveau requis.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<ComponentStructureAssignmentNode
|
||||
v-for="subAssignment in assignment.subcomponents"
|
||||
:key="subAssignment.path"
|
||||
:assignment="subAssignment"
|
||||
:pieces="pieces"
|
||||
:products="products"
|
||||
:components="components"
|
||||
:components-loading="componentsLoading"
|
||||
:pieces-loading="piecesLoading"
|
||||
:products-loading="productsLoading"
|
||||
:depth="depth + 1"
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||
import { useStructureAssignmentFetch } from '~/composables/useStructureAssignmentFetch';
|
||||
import type {
|
||||
ComponentOption,
|
||||
PieceOption,
|
||||
ProductOption,
|
||||
} from '~/composables/useStructureAssignmentFetch';
|
||||
|
||||
export type {
|
||||
StructureAssignmentNode,
|
||||
StructurePieceAssignment,
|
||||
StructureProductAssignment,
|
||||
} from '~/composables/useStructureAssignmentFetch';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
assignment: import('~/composables/useStructureAssignmentFetch').StructureAssignmentNode;
|
||||
pieces: PieceOption[] | null;
|
||||
products: ProductOption[] | null;
|
||||
components: ComponentOption[] | null;
|
||||
depth?: number;
|
||||
componentsLoading?: boolean;
|
||||
piecesLoading?: boolean;
|
||||
productsLoading?: boolean;
|
||||
pieceTypeLabelMap?: Record<string, string>;
|
||||
productTypeLabelMap?: Record<string, string>;
|
||||
componentTypeLabelMap?: Record<string, string>;
|
||||
}>(),
|
||||
{
|
||||
depth: 0,
|
||||
pieces: () => [],
|
||||
products: () => [],
|
||||
components: () => [],
|
||||
componentsLoading: false,
|
||||
piecesLoading: false,
|
||||
productsLoading: false,
|
||||
pieceTypeLabelMap: () => ({}),
|
||||
productTypeLabelMap: () => ({}),
|
||||
componentTypeLabelMap: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
const depth = computed(() => props.depth ?? 0);
|
||||
const isRoot = computed(() => depth.value === 0);
|
||||
|
||||
const wrapperClass = computed(() =>
|
||||
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
||||
);
|
||||
|
||||
const {
|
||||
pieceLoadingByPath,
|
||||
productLoadingByPath,
|
||||
componentLoadingByPath,
|
||||
componentOptions,
|
||||
componentOptionLabel,
|
||||
componentOptionDescription,
|
||||
fetchComponentOptions,
|
||||
getPieceOptions,
|
||||
pieceOptionLabel,
|
||||
pieceOptionDescription,
|
||||
fetchPieceOptions,
|
||||
describePieceRequirement,
|
||||
getProductOptions,
|
||||
productOptionLabel,
|
||||
productOptionDescription,
|
||||
fetchProductOptions,
|
||||
describeProductRequirement,
|
||||
} = useStructureAssignmentFetch({
|
||||
assignment: props.assignment,
|
||||
pieces: props.pieces,
|
||||
products: props.products,
|
||||
components: props.components,
|
||||
isRoot: () => isRoot.value,
|
||||
pieceTypeLabelMap: props.pieceTypeLabelMap ?? {},
|
||||
productTypeLabelMap: props.productTypeLabelMap ?? {},
|
||||
componentTypeLabelMap: props.componentTypeLabelMap ?? {},
|
||||
});
|
||||
|
||||
const normalizeSelectionValue = (value: unknown) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const requirementLabel = computed(() => {
|
||||
const definition = props.assignment.definition || {};
|
||||
const alias = definition.alias || definition.typeComposantLabel;
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
|
||||
return props.componentTypeLabelMap[definition.typeComposantId];
|
||||
}
|
||||
if (definition.typeComposant?.name) {
|
||||
return definition.typeComposant.name;
|
||||
}
|
||||
if (definition.familyCode) {
|
||||
return `Famille ${definition.familyCode}`;
|
||||
}
|
||||
return 'Sous-composant';
|
||||
});
|
||||
|
||||
const requirementDescription = computed(() => {
|
||||
const definition = props.assignment.definition || {};
|
||||
const family =
|
||||
definition.typeComposantLabel
|
||||
|| (definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null)
|
||||
|| definition.typeComposant?.name
|
||||
|| definition.familyCode;
|
||||
if (family) {
|
||||
return `Doit appartenir à la famille "${family}".`;
|
||||
}
|
||||
return 'Sélectionnez un composant enfant conforme à cette position.';
|
||||
});
|
||||
</script>
|
||||
130
frontend/app/components/ComposantSelect.vue
Normal file
130
frontend/app/components/ComposantSelect.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<SearchSelect
|
||||
:model-value="modelValue ?? undefined"
|
||||
:options="composantOptions"
|
||||
:loading="loading"
|
||||
:placeholder="placeholder"
|
||||
:empty-text="emptyText"
|
||||
size="sm"
|
||||
option-value="id"
|
||||
:option-label="formatLabel"
|
||||
:disabled="disabled"
|
||||
server-search
|
||||
@update:modelValue="updateValue"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #option-description="{ option }">
|
||||
<span class="text-xs text-base-content/60">
|
||||
{{ formatDescription(option) }}
|
||||
</span>
|
||||
</template>
|
||||
</SearchSelect>
|
||||
<p v-if="helperText" class="text-xs text-base-content/60">
|
||||
{{ helperText }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
helperText?: string
|
||||
disabled?: boolean
|
||||
typeComposantId?: string | null
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: 'Sélectionner un composant…',
|
||||
emptyText: 'Aucun composant disponible',
|
||||
helperText: '',
|
||||
disabled: false,
|
||||
typeComposantId: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { loading: globalLoading, loadComposants } = useComposants()
|
||||
|
||||
const localComposants = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
const composantOptions = computed(() => localComposants.value)
|
||||
|
||||
const loadFilteredComposants = async (search = '') => {
|
||||
if (!props.typeComposantId) return
|
||||
localLoading.value = true
|
||||
try {
|
||||
const result = await loadComposants({ typeComposantId: props.typeComposantId, search, itemsPerPage: 200, force: true })
|
||||
if (result.success && result.data?.items) {
|
||||
localComposants.value = result.data.items
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
const handleSearch = (term: string) => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => loadFilteredComposants(term.trim()), 300)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFilteredComposants()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.typeComposantId,
|
||||
() => {
|
||||
loadFilteredComposants()
|
||||
},
|
||||
)
|
||||
|
||||
const updateValue = (value: string | number | null | undefined) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', String(value))
|
||||
}
|
||||
|
||||
const formatLabel = (option: any) => {
|
||||
if (!option) return ''
|
||||
const name = option.name || 'Composant'
|
||||
return option.reference ? `${name} — ${option.reference}` : name
|
||||
}
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typeComposant?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.prix !== undefined && option.prix !== null) {
|
||||
const price = Number(option.prix)
|
||||
if (!Number.isNaN(price)) {
|
||||
parts.push(`${price.toFixed(2)} €`)
|
||||
}
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Sans référence'
|
||||
}
|
||||
</script>
|
||||
93
frontend/app/components/ConstructeurLinksTable.vue
Normal file
93
frontend/app/components/ConstructeurLinksTable.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<template>
|
||||
<div v-if="modelValue.length" class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fournisseur</th>
|
||||
<th>Réf. fournisseur</th>
|
||||
<th v-if="!readonly" class="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(link, index) in modelValue" :key="link.constructeurId">
|
||||
<td class="font-medium">
|
||||
{{ getConstructeurName(link) }}
|
||||
<div v-if="getConstructeurContact(link)" class="text-xs text-gray-500">
|
||||
{{ getConstructeurContact(link) }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
v-if="!readonly"
|
||||
:value="link.supplierReference || ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Réf. fournisseur"
|
||||
@input="updateReference(index, ($event.target as HTMLInputElement).value)"
|
||||
>
|
||||
<span v-else>{{ link.supplierReference || '—' }}</span>
|
||||
</td>
|
||||
<td v-if="!readonly">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
aria-label="Retirer"
|
||||
@click="removeLink(index)"
|
||||
>
|
||||
<IconLucideX class="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PropType } from 'vue'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
import { formatConstructeurContact } from '~/shared/constructeurUtils'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<ConstructeurLinkEntry[]>,
|
||||
default: () => [],
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: ConstructeurLinkEntry[]): void
|
||||
(e: 'remove', constructeurId: string): void
|
||||
}>()
|
||||
|
||||
const { getConstructeurById } = useConstructeurs()
|
||||
|
||||
const getConstructeurName = (link: ConstructeurLinkEntry): string =>
|
||||
link.constructeur?.name || getConstructeurById(link.constructeurId)?.name || link.constructeurId
|
||||
|
||||
const getConstructeurContact = (link: ConstructeurLinkEntry): string => {
|
||||
const c = link.constructeur || getConstructeurById(link.constructeurId)
|
||||
return formatConstructeurContact(c as any)
|
||||
}
|
||||
|
||||
const updateReference = (index: number, value: string) => {
|
||||
const updated = [...props.modelValue]
|
||||
const entry = updated[index]
|
||||
if (!entry) return
|
||||
updated[index] = { ...entry, supplierReference: value || null }
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
const removeLink = (index: number) => {
|
||||
const removed = props.modelValue[index]
|
||||
const updated = props.modelValue.filter((_, i) => i !== index)
|
||||
emit('update:modelValue', updated)
|
||||
if (removed) emit('remove', removed.constructeurId)
|
||||
}
|
||||
</script>
|
||||
395
frontend/app/components/ConstructeurSelect.vue
Normal file
395
frontend/app/components/ConstructeurSelect.vue
Normal file
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<div class="space-y-2 constructeur-select">
|
||||
<label v-if="label" class="label"><span class="label-text">{{ label }}</span></label>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
class="input input-bordered w-full pr-10"
|
||||
:placeholder="placeholder"
|
||||
@focus="openDropdown = true; ensureOptionsLoaded()"
|
||||
@input="onSearch"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs"
|
||||
@click="ensureOptionsLoaded(true)"
|
||||
>
|
||||
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div
|
||||
v-if="openDropdown"
|
||||
class="absolute z-20 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg flex flex-col"
|
||||
>
|
||||
<div
|
||||
v-if="filteredOptions.length === 0"
|
||||
class="px-3 py-2 text-xs text-gray-500"
|
||||
>
|
||||
Aucun fournisseur trouvé
|
||||
</div>
|
||||
<button
|
||||
v-for="option in filteredOptions"
|
||||
:key="option.id"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
:class="{ 'bg-base-200': isSelected(option.id) }"
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ option.name }}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ formatConstructeurContact(option) || '—' }}
|
||||
</span>
|
||||
</div>
|
||||
<IconLucideCheck v-if="isSelected(option.id)" class="w-4 h-4 text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="openCreateModal = true">
|
||||
Nouveau
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 min-h-[1.5rem]">
|
||||
<span v-if="!selectedConstructeurs.length" class="text-sm text-gray-500">
|
||||
Aucun fournisseur sélectionné
|
||||
</span>
|
||||
<span
|
||||
v-for="constructeur in selectedConstructeurs"
|
||||
:key="constructeur.id"
|
||||
class="badge badge-outline gap-1"
|
||||
>
|
||||
<span>{{ constructeur.name }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs p-0"
|
||||
aria-label="Retirer le fournisseur"
|
||||
@click="removeConstructeur(constructeur.id)"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': openCreateModal }">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Nouveau fournisseur
|
||||
</h3>
|
||||
<form @submit.prevent="handleCreate">
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Nom</span></label>
|
||||
<input v-model="createForm.name" type="text" class="input input-bordered" required>
|
||||
</div>
|
||||
<FieldEmail
|
||||
v-model="createForm.email"
|
||||
class="mb-3"
|
||||
label="Email"
|
||||
placeholder="ex: contact@fournisseur.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<FieldPhone
|
||||
v-model="createForm.phone"
|
||||
class="mb-3"
|
||||
label="Téléphone"
|
||||
placeholder="ex: 01 23 45 67 89"
|
||||
/>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeCreateModal">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs mr-2" />
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import {
|
||||
type ConstructeurSummary,
|
||||
formatConstructeurContact,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
} from '~/shared/constructeurUtils'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<string[]>,
|
||||
default: () => [],
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Sélectionner ou créer un fournisseur...',
|
||||
},
|
||||
initialOptions: {
|
||||
type: Array as PropType<ConstructeurSummary[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string[]): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
constructeurs,
|
||||
searchConstructeurs,
|
||||
createConstructeur,
|
||||
ensureConstructeurs,
|
||||
} = useConstructeurs()
|
||||
const searchTerm = ref('')
|
||||
const openDropdown = ref(false)
|
||||
const openCreateModal = ref(false)
|
||||
const creating = ref(false)
|
||||
const options = ref<ConstructeurSummary[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
|
||||
|
||||
const uniqueOptions = (items: ConstructeurSummary[] = []) => {
|
||||
const seen = new Map<string, ConstructeurSummary>()
|
||||
items.forEach((item) => {
|
||||
if (item && typeof item === 'object' && typeof item.id === 'string') {
|
||||
seen.set(item.id, item)
|
||||
}
|
||||
})
|
||||
return Array.from(seen.values())
|
||||
}
|
||||
|
||||
const normalizedInitialOptions = computed(() =>
|
||||
uniqueOptions((props.initialOptions as ConstructeurSummary[]) || []),
|
||||
)
|
||||
|
||||
const applyOptions = (items: ConstructeurSummary[] = []) => {
|
||||
options.value = uniqueOptions([
|
||||
...normalizedInitialOptions.value,
|
||||
...items,
|
||||
])
|
||||
}
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
if (!term) return options.value
|
||||
return options.value.filter((option) =>
|
||||
(option.name ?? '').toLowerCase().includes(term)
|
||||
|| (option.email && option.email.toLowerCase().includes(term))
|
||||
|| (option.phone && option.phone.toLowerCase().includes(term))
|
||||
)
|
||||
})
|
||||
|
||||
const createForm = ref({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const optionLookup = computed(() => {
|
||||
const map = new Map<string, ConstructeurSummary>()
|
||||
normalizedInitialOptions.value.forEach((item) => {
|
||||
map.set(item.id, item)
|
||||
})
|
||||
constructeurs.value.forEach((item: ConstructeurSummary) => {
|
||||
map.set(item.id, item)
|
||||
})
|
||||
options.value.forEach((item) => {
|
||||
map.set(item.id, item)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const selectedConstructeurs = computed<ConstructeurSummary[]>(() => {
|
||||
if (!selectedIds.value.length) {
|
||||
return []
|
||||
}
|
||||
|
||||
return selectedIds.value
|
||||
.map((id) => optionLookup.value.get(id))
|
||||
.filter((item): item is ConstructeurSummary => Boolean(item))
|
||||
})
|
||||
|
||||
const isSelected = (id: string) => selectedIds.value.includes(id)
|
||||
|
||||
const emitSelection = (ids: string[]) => {
|
||||
const normalized = uniqueConstructeurIds(ids)
|
||||
selectedIds.value = normalized
|
||||
emit('update:modelValue', normalized)
|
||||
}
|
||||
|
||||
const extractDataArray = (data: unknown): ConstructeurSummary[] => {
|
||||
if (Array.isArray(data)) {
|
||||
return data as ConstructeurSummary[]
|
||||
}
|
||||
if (data && typeof data === 'object' && 'id' in data) {
|
||||
return [data as ConstructeurSummary]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const ensureOptionsLoaded = async (force = false) => {
|
||||
if (!force && constructeurs.value.length) {
|
||||
applyOptions(constructeurs.value as ConstructeurSummary[])
|
||||
return
|
||||
}
|
||||
|
||||
const result = await searchConstructeurs('')
|
||||
if (result.success) {
|
||||
applyOptions(extractDataArray(result.data))
|
||||
}
|
||||
}
|
||||
|
||||
const onSearch = () => {
|
||||
openDropdown.value = true
|
||||
ensureOptionsLoaded()
|
||||
}
|
||||
|
||||
const toggleOption = (option: ConstructeurSummary) => {
|
||||
const ids = new Set(selectedIds.value)
|
||||
if (ids.has(option.id)) {
|
||||
ids.delete(option.id)
|
||||
} else {
|
||||
ids.add(option.id)
|
||||
}
|
||||
emitSelection(Array.from(ids))
|
||||
}
|
||||
|
||||
const removeConstructeur = (id: string) => {
|
||||
emitSelection(selectedIds.value.filter((item) => item !== id))
|
||||
}
|
||||
|
||||
const closeCreateModal = () => {
|
||||
openCreateModal.value = false
|
||||
createForm.value = { name: '', email: '', phone: '' }
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
const trimmedName = createForm.value.name.trim()
|
||||
const duplicate = options.value.find(
|
||||
(o) => (o.name ?? '').toLowerCase() === trimmedName.toLowerCase(),
|
||||
)
|
||||
if (duplicate) {
|
||||
emitSelection([...selectedIds.value, duplicate.id])
|
||||
closeCreateModal()
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
const payload: { name: string; email?: string; phone?: string } = {
|
||||
name: trimmedName,
|
||||
}
|
||||
if (createForm.value.email) {
|
||||
payload.email = createForm.value.email
|
||||
}
|
||||
if (createForm.value.phone) {
|
||||
payload.phone = createForm.value.phone
|
||||
}
|
||||
const result = await createConstructeur(payload)
|
||||
creating.value = false
|
||||
if (result.success && result.data && !Array.isArray(result.data)) {
|
||||
emitSelection([...selectedIds.value, result.data.id])
|
||||
searchTerm.value = ''
|
||||
closeCreateModal()
|
||||
await ensureOptionsLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
const clickHandler = (event: Event) => {
|
||||
const element = event.target as HTMLElement | null
|
||||
if (element && element.closest) {
|
||||
if (
|
||||
element.closest('.menu') ||
|
||||
element.closest('.modal-box') ||
|
||||
element.closest('.btn') ||
|
||||
element.closest('.constructeur-select')
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedIds.value = uniqueConstructeurIds(newValue)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedIds,
|
||||
async (ids) => {
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
const missing = ids.some((id) => !optionLookup.value.get(id))
|
||||
if (missing) {
|
||||
const fetched = await ensureConstructeurs(ids)
|
||||
if (fetched.length) {
|
||||
applyOptions([...options.value, ...fetched])
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
constructeurs,
|
||||
(list) => {
|
||||
applyOptions((list as ConstructeurSummary[]) || [])
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
normalizedInitialOptions,
|
||||
() => {
|
||||
applyOptions(options.value)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', clickHandler)
|
||||
ensureOptionsLoaded()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', clickHandler)
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedIds,
|
||||
(ids) => {
|
||||
// ensure options contain newly selected ids
|
||||
const resolved = resolveConstructeurs(
|
||||
ids,
|
||||
constructeurs.value as ConstructeurSummary[],
|
||||
options.value,
|
||||
)
|
||||
if (resolved.length) {
|
||||
applyOptions([...resolved, ...options.value])
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
147
frontend/app/components/CustomFieldsDisplay.vue
Normal file
147
frontend/app/components/CustomFieldsDisplay.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div v-if="customFields && customFields.length > 0" class="space-y-4">
|
||||
<h4 class="font-semibold text-base-content/80 mb-3">
|
||||
Champs personnalisés
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in sortedCustomFields"
|
||||
:key="field.id"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
|
||||
<!-- Champ de type TEXT -->
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="fieldValues[field.id]"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@blur="updateCustomFieldValue(field.id)"
|
||||
>
|
||||
|
||||
<!-- Champ de type NUMBER -->
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="fieldValues[field.id]"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@blur="updateCustomFieldValue(field.id)"
|
||||
>
|
||||
|
||||
<!-- Champ de type SELECT -->
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="fieldValues[field.id]"
|
||||
class="select select-bordered select-sm"
|
||||
:required="field.required"
|
||||
@change="updateCustomFieldValue(field.id)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner...
|
||||
</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Champ de type BOOLEAN -->
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="fieldValues[field.id]"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
:checked="fieldValues[field.id] === 'true'"
|
||||
@change="updateCustomFieldValue(field.id)"
|
||||
>
|
||||
<span class="text-sm" :class="fieldValues[field.id] === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ fieldValues[field.id] === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Champ de type DATE -->
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="fieldValues[field.id]"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@blur="updateCustomFieldValue(field.id)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, onMounted, watch, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
customFields: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
entityId: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
entityType: {
|
||||
type: String,
|
||||
required: true, // 'machine', 'composant', 'piece'
|
||||
validator: value => ['machine', 'composant', 'piece'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const sortedCustomFields = computed(() => {
|
||||
if (!Array.isArray(props.customFields)) {
|
||||
return []
|
||||
}
|
||||
return [...props.customFields].sort((a, b) => {
|
||||
const left = typeof a?.orderIndex === 'number' ? a.orderIndex : 0
|
||||
const right = typeof b?.orderIndex === 'number' ? b.orderIndex : 0
|
||||
return left - right
|
||||
})
|
||||
})
|
||||
|
||||
// Valeurs des champs personnalisés
|
||||
const fieldValues = reactive({})
|
||||
|
||||
// Initialiser les valeurs sans appliquer de valeur par défaut implicite
|
||||
const initializeFieldValues = () => {
|
||||
props.customFields.forEach((field) => {
|
||||
if (!(field.id in fieldValues)) {
|
||||
fieldValues[field.id] = field.value ?? ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mettre à jour la valeur d'un champ personnalisé
|
||||
const updateCustomFieldValue = (fieldId) => {
|
||||
const field = props.customFields.find(f => f.id === fieldId)
|
||||
if (field) {
|
||||
emit('update', {
|
||||
fieldId,
|
||||
value: fieldValues[fieldId],
|
||||
field
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Surveiller les changements dans les champs personnalisés
|
||||
watch(() => props.customFields, () => {
|
||||
initializeFieldValues()
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
initializeFieldValues()
|
||||
})
|
||||
</script>
|
||||
51
frontend/app/components/DetailHeader.vue
Normal file
51
frontend/app/components/DetailHeader.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-bold">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="text-sm text-base-content/70">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-outline': isEditMode }"
|
||||
@click="$emit('toggle-edit')"
|
||||
>
|
||||
<IconLucideSquarePen v-if="!isEditMode" class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
<IconLucideEye v-else class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
Retour au catalogue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
isEditMode: boolean
|
||||
canEdit: boolean
|
||||
backLink: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-edit': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
}
|
||||
else {
|
||||
navigateTo(props.backLink)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
207
frontend/app/components/DisplaySettings.vue
Normal file
207
frontend/app/components/DisplaySettings.vue
Normal file
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<div class="modal" :class="{ 'modal-open': isOpen }">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Paramètres d'affichage
|
||||
</h3>
|
||||
|
||||
<!-- Contrôle du zoom -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Taille du texte</span>
|
||||
<span class="label-text-alt">{{ zoomLevel }}%</span>
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="80"
|
||||
max="150"
|
||||
step="10"
|
||||
:value="zoomLevel"
|
||||
class="range range-primary"
|
||||
@input="updateZoom"
|
||||
>
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>80%</span>
|
||||
<span>100%</span>
|
||||
<span>150%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contrôle de la densité -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Densité de l'interface</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="density === 'compact' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="setDensity('compact')"
|
||||
>
|
||||
Compacte
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="density === 'comfortable' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="setDensity('comfortable')"
|
||||
>
|
||||
Confortable
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="density === 'spacious' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="setDensity('spacious')"
|
||||
>
|
||||
Espacée
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contrôle du contraste -->
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Contraste</span>
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="contrast === 'normal' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="setContrast('normal')"
|
||||
>
|
||||
Normal
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="contrast === 'high' ? 'btn-primary' : 'btn-outline'"
|
||||
@click="setContrast('high')"
|
||||
>
|
||||
Élevé
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Réinitialiser -->
|
||||
<div class="form-control">
|
||||
<button
|
||||
class="btn btn-outline btn-sm"
|
||||
@click="resetSettings"
|
||||
>
|
||||
Réinitialiser les paramètres
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-primary" @click="closeModal">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
defineProps({
|
||||
isOpen: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'update-settings'])
|
||||
|
||||
// Paramètres d'affichage
|
||||
const zoomLevel = ref(100)
|
||||
const density = ref('comfortable')
|
||||
const contrast = ref('normal')
|
||||
|
||||
// Charger les paramètres depuis le localStorage
|
||||
onMounted(() => {
|
||||
const savedZoom = localStorage.getItem('display-zoom')
|
||||
const savedDensity = localStorage.getItem('display-density')
|
||||
const savedContrast = localStorage.getItem('display-contrast')
|
||||
|
||||
if (savedZoom) { zoomLevel.value = parseInt(savedZoom) }
|
||||
if (savedDensity) { density.value = savedDensity }
|
||||
if (savedContrast) { contrast.value = savedContrast }
|
||||
|
||||
applySettings()
|
||||
})
|
||||
|
||||
const updateZoom = (event) => {
|
||||
zoomLevel.value = parseInt(event.target.value)
|
||||
applySettings()
|
||||
}
|
||||
|
||||
const setDensity = (newDensity) => {
|
||||
density.value = newDensity
|
||||
applySettings()
|
||||
}
|
||||
|
||||
const setContrast = (newContrast) => {
|
||||
contrast.value = newContrast
|
||||
applySettings()
|
||||
}
|
||||
|
||||
const applySettings = () => {
|
||||
// Sauvegarder dans localStorage
|
||||
localStorage.setItem('display-zoom', zoomLevel.value.toString())
|
||||
localStorage.setItem('display-density', density.value)
|
||||
localStorage.setItem('display-contrast', contrast.value)
|
||||
|
||||
// Appliquer les styles
|
||||
const root = document.documentElement
|
||||
|
||||
// Zoom - exclure complètement le modal des paramètres
|
||||
const modal = document.querySelector('.modal')
|
||||
if (modal) {
|
||||
// Forcer la taille normale pour le modal et tous ses enfants
|
||||
modal.style.fontSize = '100%'
|
||||
modal.style.transform = 'none'
|
||||
modal.style.scale = '1'
|
||||
|
||||
// Appliquer aux enfants du modal
|
||||
const modalElements = modal.querySelectorAll('*')
|
||||
modalElements.forEach((element) => {
|
||||
element.style.fontSize = 'inherit'
|
||||
element.style.transform = 'none'
|
||||
element.style.scale = '1'
|
||||
})
|
||||
}
|
||||
|
||||
// Appliquer le zoom au reste de la page (sauf le modal)
|
||||
root.style.fontSize = `${zoomLevel.value}%`
|
||||
|
||||
// Densité - utiliser les classes DaisyUI
|
||||
root.classList.remove('density-compact', 'density-comfortable', 'density-spacious')
|
||||
root.classList.add(`density-${density.value}`)
|
||||
|
||||
// Contraste - utiliser les classes DaisyUI
|
||||
root.classList.remove('contrast-normal', 'contrast-high')
|
||||
root.classList.add(`contrast-${contrast.value}`)
|
||||
|
||||
// Émettre les changements
|
||||
emit('update-settings', {
|
||||
zoom: zoomLevel.value,
|
||||
density: density.value,
|
||||
contrast: contrast.value
|
||||
})
|
||||
}
|
||||
|
||||
const resetSettings = () => {
|
||||
zoomLevel.value = 100
|
||||
density.value = 'comfortable'
|
||||
contrast.value = 'normal'
|
||||
applySettings()
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Les styles sont maintenant gérés par DaisyUI et le CSS global */
|
||||
</style>
|
||||
90
frontend/app/components/DocumentEditModal.vue
Normal file
90
frontend/app/components/DocumentEditModal.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="modal modal-open" @click.self="$emit('close')">
|
||||
<div class="modal-box max-w-sm">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Modifier le document
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Nom</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md w-full"
|
||||
>
|
||||
</label>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span class="label-text">Type</span>
|
||||
</div>
|
||||
<select
|
||||
v-model="form.type"
|
||||
class="select select-bordered select-sm md:select-md w-full"
|
||||
>
|
||||
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="$emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm md:btn-md"
|
||||
:disabled="saving"
|
||||
@click="save"
|
||||
>
|
||||
<span v-if="saving" class="loading loading-spinner loading-xs" />
|
||||
Sauvegarder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch, ref } from 'vue'
|
||||
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||
import type { Document } from '~/composables/useDocuments'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
document: Document | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'updated', data: { name: string; type: string }): void
|
||||
}>()
|
||||
|
||||
const form = reactive({ name: '', type: 'documentation' })
|
||||
const saving = ref(false)
|
||||
|
||||
watch(
|
||||
() => props.document,
|
||||
(doc) => {
|
||||
if (doc) {
|
||||
form.name = doc.name || ''
|
||||
form.type = doc.type || 'documentation'
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const save = () => {
|
||||
if (!form.name.trim()) return
|
||||
saving.value = true
|
||||
emit('updated', { name: form.name.trim(), type: form.type })
|
||||
saving.value = false
|
||||
}
|
||||
</script>
|
||||
250
frontend/app/components/DocumentPreviewModal.vue
Normal file
250
frontend/app/components/DocumentPreviewModal.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[1200] flex items-center justify-center bg-black/60 backdrop-blur-sm px-4 py-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="w-full max-w-[1600px] h-full max-h-[94vh] bg-base-100 rounded-2xl shadow-2xl flex flex-col overflow-hidden">
|
||||
<header class="flex items-start justify-between gap-4 p-6 border-b border-base-200">
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-bold text-xl truncate">
|
||||
Prévisualisation
|
||||
<span v-if="navTotal > 1" class="text-base font-normal text-base-content/50">
|
||||
{{ activeIndex + 1 }} / {{ navTotal }}
|
||||
</span>
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/50 truncate">
|
||||
{{ activeDoc?.name || activeDoc?.filename }}<span v-if="documentDescription"> • {{ documentDescription }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm shrink-0" @click="close">
|
||||
✕
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="flex-1 bg-base-200/40 px-6 py-5 overflow-hidden relative">
|
||||
<button
|
||||
v-if="hasPrev"
|
||||
type="button"
|
||||
class="absolute left-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
|
||||
title="Document précédent (←)"
|
||||
@click="goToPrev"
|
||||
>
|
||||
❮
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasNext"
|
||||
type="button"
|
||||
class="absolute right-8 top-1/2 -translate-y-1/2 z-10 btn btn-circle bg-base-100/80 hover:bg-base-100 shadow-lg border-base-300"
|
||||
title="Document suivant (→)"
|
||||
@click="goToNext"
|
||||
>
|
||||
❯
|
||||
</button>
|
||||
|
||||
<div class="h-full w-full rounded-xl border border-base-300 bg-base-100 flex items-center justify-center overflow-hidden">
|
||||
<template v-if="previewType === 'image'">
|
||||
<img :src="documentSrc" alt="preview" class="max-h-full max-w-full object-contain">
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'pdf'">
|
||||
<iframe
|
||||
:src="documentSrc"
|
||||
class="w-full h-full bg-white"
|
||||
frameborder="0"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'audio'">
|
||||
<audio :src="documentSrc" controls class="w-full" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'video'">
|
||||
<video :src="documentSrc" controls class="w-full h-full bg-black" />
|
||||
</template>
|
||||
|
||||
<template v-else-if="previewType === 'text'">
|
||||
<div class="w-full h-full overflow-auto">
|
||||
<div v-if="textLoading" class="flex items-center justify-center py-10 text-sm text-base-content/50">
|
||||
<span class="loading loading-spinner loading-md mr-2" />
|
||||
Chargement du document...
|
||||
</div>
|
||||
<div v-else-if="textError" class="alert alert-error text-sm">
|
||||
{{ textError }}
|
||||
</div>
|
||||
<pre v-else class="bg-base-100 border border-base-300 rounded-lg p-4 whitespace-pre-wrap">
|
||||
{{ textContent }}
|
||||
</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="text-sm text-base-content/50 text-center px-6">
|
||||
Prévisualisation non disponible pour ce type de document.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="border-t border-base-200 px-6 py-4 flex flex-wrap gap-2 justify-end bg-base-100">
|
||||
<button type="button" class="btn" @click="close">
|
||||
Fermer
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="download">
|
||||
Télécharger
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||
import { getPreviewType, describeDocument, canPreviewDocument } from '~/utils/documentPreview'
|
||||
|
||||
const props = defineProps({
|
||||
document: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
documents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// --- Carousel navigation ---
|
||||
|
||||
const previewableDocuments = computed(() => {
|
||||
if (!props.documents?.length) return []
|
||||
return props.documents.filter((doc) => canPreviewDocument(doc))
|
||||
})
|
||||
|
||||
const navTotal = computed(() => previewableDocuments.value.length)
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
// Sync index when the parent changes the document prop (e.g. user clicks a different "Consulter")
|
||||
watch(
|
||||
() => props.document,
|
||||
(doc) => {
|
||||
if (!doc || !previewableDocuments.value.length) {
|
||||
activeIndex.value = 0
|
||||
return
|
||||
}
|
||||
const idx = previewableDocuments.value.findIndex((d) => d.id === doc.id)
|
||||
activeIndex.value = idx >= 0 ? idx : 0
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const activeDoc = computed(() => {
|
||||
if (previewableDocuments.value.length && activeIndex.value < previewableDocuments.value.length) {
|
||||
return previewableDocuments.value[activeIndex.value]
|
||||
}
|
||||
return props.document
|
||||
})
|
||||
|
||||
const hasPrev = computed(() => navTotal.value > 1 && activeIndex.value > 0)
|
||||
const hasNext = computed(() => navTotal.value > 1 && activeIndex.value < navTotal.value - 1)
|
||||
|
||||
const goToPrev = () => {
|
||||
if (hasPrev.value) activeIndex.value--
|
||||
}
|
||||
const goToNext = () => {
|
||||
if (hasNext.value) activeIndex.value++
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
const handleKeydown = (e) => {
|
||||
if (!props.visible) return
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
goToPrev()
|
||||
} else if (e.key === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
goToNext()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// --- Preview logic (uses activeDoc) ---
|
||||
|
||||
const previewType = computed(() => getPreviewType(activeDoc.value))
|
||||
const documentDescription = computed(() => describeDocument(activeDoc.value))
|
||||
const documentSrc = computed(() => activeDoc.value?.fileUrl || activeDoc.value?.path || '')
|
||||
|
||||
const textContent = ref('')
|
||||
const textLoading = ref(false)
|
||||
const textError = ref('')
|
||||
|
||||
watch(
|
||||
activeDoc,
|
||||
async (doc) => {
|
||||
textContent.value = ''
|
||||
textError.value = ''
|
||||
textLoading.value = false
|
||||
|
||||
if (!doc) { return }
|
||||
if (getPreviewType(doc) !== 'text') { return }
|
||||
|
||||
try {
|
||||
textLoading.value = true
|
||||
const url = doc.fileUrl || doc.path || ''
|
||||
if (!url) {
|
||||
textError.value = 'Aucune URL de document disponible.'
|
||||
return
|
||||
}
|
||||
|
||||
const response = await fetch(url, { credentials: 'include' })
|
||||
if (!response.ok) {
|
||||
throw new Error('Téléchargement du document impossible')
|
||||
}
|
||||
textContent.value = await response.text()
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement du texte:', error)
|
||||
textError.value = error.message || 'Impossible de lire ce document.'
|
||||
} finally {
|
||||
textLoading.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const download = () => {
|
||||
const url = activeDoc.value?.downloadUrl || activeDoc.value?.fileUrl || activeDoc.value?.path
|
||||
if (!url) { return }
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
105
frontend/app/components/DocumentThumbnail.vue
Normal file
105
frontend/app/components/DocumentThumbnail.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="document"
|
||||
class="flex items-center justify-center overflow-hidden rounded-md border border-base-200 bg-base-200/70"
|
||||
:class="thumbnailClass"
|
||||
>
|
||||
<img
|
||||
v-if="canRenderImage"
|
||||
:src="previewSrc"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="altText"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
<component
|
||||
v-else
|
||||
:is="icon.component"
|
||||
class="h-6 w-6"
|
||||
:class="icon.colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-16 w-16 items-center justify-center rounded-md border border-dashed border-base-200 bg-base-200/40 text-xs text-base-content/40"
|
||||
aria-hidden="true"
|
||||
>
|
||||
—
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { getFileIcon } from '~/utils/fileIcons';
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview';
|
||||
|
||||
type GenericDocument = {
|
||||
id?: string | number;
|
||||
name?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
path?: string | null;
|
||||
fileUrl?: string | null;
|
||||
downloadUrl?: string | null;
|
||||
size?: number | null;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
document: GenericDocument | null | undefined;
|
||||
alt?: string;
|
||||
}>();
|
||||
|
||||
const normalizedDocument = computed(() => props.document ?? null);
|
||||
|
||||
const canRenderImage = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
return !!(doc && isImageDocument(doc) && (doc.fileUrl || doc.path));
|
||||
});
|
||||
|
||||
const canRenderPdf = computed(() => {
|
||||
// Rendering many PDF iframes in a list is very heavy for the browser.
|
||||
// We intentionally disable inline PDF previews and fall back to an icon.
|
||||
return false;
|
||||
});
|
||||
|
||||
const appendPdfViewerParams = (src: string) => {
|
||||
if (!src || src.startsWith('data:')) {
|
||||
return src;
|
||||
}
|
||||
if (src.includes('#')) {
|
||||
return `${src}&toolbar=0&navpanes=0`;
|
||||
}
|
||||
return `${src}#toolbar=0&navpanes=0`;
|
||||
};
|
||||
|
||||
const previewSrc = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
const url = doc?.fileUrl || doc?.path;
|
||||
if (!doc || !url) {
|
||||
return '';
|
||||
}
|
||||
if (isPdfDocument(doc)) {
|
||||
return appendPdfViewerParams(url);
|
||||
}
|
||||
return url;
|
||||
});
|
||||
|
||||
const thumbnailClass = computed(() => (canRenderImage.value || canRenderPdf.value ? 'h-20 w-16' : 'h-16 w-16'));
|
||||
|
||||
const icon = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
return getFileIcon({
|
||||
name: doc?.filename || doc?.name || '',
|
||||
mime: doc?.mimeType || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
const altText = computed(() => {
|
||||
if (props.alt) {
|
||||
return props.alt;
|
||||
}
|
||||
const doc = normalizedDocument.value;
|
||||
return doc?.name ? `Aperçu de ${doc.name}` : 'Aperçu du document';
|
||||
});
|
||||
</script>
|
||||
245
frontend/app/components/DocumentUpload.vue
Normal file
245
frontend/app/components/DocumentUpload.vue
Normal file
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-lg p-6 transition-colors"
|
||||
:class="dragActive ? 'border-primary bg-primary/5' : 'border-base-300 bg-base-100'"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-3 text-center">
|
||||
<IconLucideCloudUpload class="w-10 h-10 text-primary" aria-hidden="true" />
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/50">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="triggerFileDialog">
|
||||
Sélectionner des fichiers
|
||||
</button>
|
||||
<span class="text-xs text-base-content/50">ou glisser-déposer ici</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
class="hidden"
|
||||
:accept="accept"
|
||||
:multiple="multiple"
|
||||
@change="onFileChange"
|
||||
>
|
||||
|
||||
<div class="w-full max-w-xs mt-2">
|
||||
<label class="text-xs font-semibold uppercase tracking-wide text-base-content/70">
|
||||
Type de document
|
||||
</label>
|
||||
<select
|
||||
class="select select-bordered select-sm w-full mt-1"
|
||||
:value="documentType"
|
||||
@change="emit('update:documentType', $event.target.value)"
|
||||
>
|
||||
<option v-for="t in DOCUMENT_TYPES" :key="t.value" :value="t.value">
|
||||
{{ t.label }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<ul v-if="selectedFiles.length" class="mt-4 w-full space-y-2 text-left">
|
||||
<li v-for="file in selectedFiles" :key="file.name" class="flex items-center justify-between text-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-14 w-14 flex-shrink-0 overflow-hidden rounded-md border border-base-300 bg-base-200/70 flex items-center justify-center">
|
||||
<img
|
||||
v-if="isImageFile(file)"
|
||||
:src="getFilePreview(file)"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${file.name}`"
|
||||
>
|
||||
<component
|
||||
v-else
|
||||
:is="getIcon(file).component"
|
||||
class="h-6 w-6"
|
||||
:class="getIcon(file).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{{ file.name }}</span>
|
||||
<span class="text-xs text-base-content/50">{{ formatSize(file.size) }} • {{ file.type || 'Type inconnu' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="removeFile(file)">
|
||||
Retirer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { DOCUMENT_TYPES } from '~/shared/documentTypes'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import IconLucideCloudUpload from '~icons/lucide/cloud-upload'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Ajouter des documents'
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: 'Formats acceptés : PDF, images, textes…'
|
||||
},
|
||||
accept: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
maxFileSizeMb: {
|
||||
type: Number,
|
||||
default: 200
|
||||
},
|
||||
documentType: {
|
||||
type: String,
|
||||
default: 'documentation'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'files-added', 'update:documentType'])
|
||||
|
||||
const dragActive = ref(false)
|
||||
const fileInput = ref(null)
|
||||
const internalFiles = ref([])
|
||||
const { showError } = useToast()
|
||||
const previewUrls = new Map()
|
||||
|
||||
const isImageFile = (file) => (file?.type || '').startsWith('image/')
|
||||
|
||||
const getFilePreview = (file) => {
|
||||
if (!isImageFile(file)) { return null }
|
||||
if (!previewUrls.has(file)) {
|
||||
previewUrls.set(file, URL.createObjectURL(file))
|
||||
}
|
||||
return previewUrls.get(file)
|
||||
}
|
||||
|
||||
const cleanupRemovedPreviews = (previousFiles = [], nextFiles = []) => {
|
||||
const nextSet = new Set(nextFiles)
|
||||
previousFiles.forEach((file) => {
|
||||
if (!nextSet.has(file)) {
|
||||
const url = previewUrls.get(file)
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url)
|
||||
previewUrls.delete(file)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectedFiles = internalFiles
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (Array.isArray(newValue)) {
|
||||
cleanupRemovedPreviews(internalFiles.value, newValue)
|
||||
internalFiles.value = [...newValue]
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const triggerFileDialog = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const emitFiles = (files) => {
|
||||
cleanupRemovedPreviews(internalFiles.value, files)
|
||||
internalFiles.value = files
|
||||
emit('update:modelValue', files)
|
||||
emit('files-added', files)
|
||||
}
|
||||
|
||||
const handleFiles = (fileList) => {
|
||||
const files = Array.from(fileList)
|
||||
const maxBytes = props.maxFileSizeMb * 1024 * 1024
|
||||
if (!props.multiple) {
|
||||
const validFile = files[0]
|
||||
if (validFile && validFile.size > maxBytes) {
|
||||
showError(`Le fichier "${validFile.name}" dépasse la limite de ${props.maxFileSizeMb} Mo`)
|
||||
return
|
||||
}
|
||||
emitFiles(files.slice(0, 1))
|
||||
} else {
|
||||
const merged = [...internalFiles.value]
|
||||
files.forEach((file) => {
|
||||
if (file.size > maxBytes) {
|
||||
showError(`Le fichier "${file.name}" dépasse la limite de ${props.maxFileSizeMb} Mo`)
|
||||
return
|
||||
}
|
||||
if (!merged.some(existing => existing.name === file.name && existing.size === file.size)) {
|
||||
merged.push(file)
|
||||
}
|
||||
})
|
||||
emitFiles(merged)
|
||||
}
|
||||
}
|
||||
|
||||
const onFileChange = (event) => {
|
||||
handleFiles(event.target.files || [])
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
const onDragOver = () => {
|
||||
dragActive.value = true
|
||||
}
|
||||
|
||||
const onDragLeave = () => {
|
||||
dragActive.value = false
|
||||
}
|
||||
|
||||
const onDrop = (event) => {
|
||||
dragActive.value = false
|
||||
if (event.dataTransfer?.files?.length) {
|
||||
handleFiles(event.dataTransfer.files)
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (fileToRemove) => {
|
||||
const filtered = internalFiles.value.filter(file => file !== fileToRemove)
|
||||
emitFiles(filtered)
|
||||
}
|
||||
|
||||
const formatSize = (size) => {
|
||||
if (!size) { return '0 B' }
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const index = Math.floor(Math.log(size) / Math.log(1024))
|
||||
const formatted = size / Math.pow(1024, index)
|
||||
return `${formatted.toFixed(1)} ${units[index]}`
|
||||
}
|
||||
|
||||
const getIcon = (file) => {
|
||||
return getFileIcon({ name: file.name, mime: file.type })
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
previewUrls.forEach((url) => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
previewUrls.clear()
|
||||
})
|
||||
</script>
|
||||
138
frontend/app/components/MachinePrintSelectionModal.vue
Normal file
138
frontend/app/components/MachinePrintSelectionModal.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<form method="dialog" class="modal-close" @submit.prevent />
|
||||
<h3 class="font-bold text-lg mb-2">
|
||||
Préparer l'impression
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 mb-4">
|
||||
Choisissez les sections à inclure avant de lancer l'impression.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button type="button" class="btn btn-xs btn-outline" @click="emit('select-all')">
|
||||
Tout sélectionner
|
||||
</button>
|
||||
<button type="button" class="btn btn-xs btn-outline" @click="emit('deselect-all')">
|
||||
Tout désélectionner
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[420px] overflow-y-auto pr-2 space-y-6">
|
||||
<section class="bg-base-200/50 rounded-xl p-4 space-y-3">
|
||||
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
|
||||
Machine
|
||||
</h4>
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
v-model="selection.machine.info"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary mt-1"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">Informations générales</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Nom, site et fournisseur de la machine.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
v-model="selection.machine.customFields"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary mt-1"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">Champs personnalisés</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Valeurs spécifiques configurées pour cette machine.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
v-model="selection.machine.documents"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary mt-1"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">Documents</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Pièces jointes liées directement à la machine.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section v-if="hasComponents" class="bg-base-200/30 rounded-xl p-4 space-y-3">
|
||||
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
|
||||
Composants & pièces
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<MachinePrintSelectionNode
|
||||
v-for="component in componentsList"
|
||||
:key="component.id"
|
||||
:component="component"
|
||||
:selection="selection"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="hasPieces" class="bg-base-200/30 rounded-xl p-4 space-y-3">
|
||||
<h4 class="font-semibold text-sm uppercase tracking-wide text-base-content/70">
|
||||
Pièces indépendantes
|
||||
</h4>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
v-for="piece in piecesList"
|
||||
:key="piece.id"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<input
|
||||
v-model="selection.pieces[piece.id]"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-secondary mt-1"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ piece.name }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ piece.reference || 'Référence inconnue' }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click="emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="emit('confirm')">
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue'
|
||||
import MachinePrintSelectionNode from '~/components/MachinePrintSelectionNode.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
selection: { type: Object, required: true },
|
||||
components: { type: Array, default: () => [] },
|
||||
pieces: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'confirm', 'select-all', 'deselect-all'])
|
||||
|
||||
const selection = toRef(props, 'selection')
|
||||
const componentsList = computed(() => props.components || [])
|
||||
const piecesList = computed(() => props.pieces || [])
|
||||
|
||||
const hasComponents = computed(() => componentsList.value.length > 0)
|
||||
const hasPieces = computed(() => piecesList.value.length > 0)
|
||||
</script>
|
||||
63
frontend/app/components/MachinePrintSelectionNode.vue
Normal file
63
frontend/app/components/MachinePrintSelectionNode.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="rounded-lg border border-base-300 bg-base-100/80 p-3 space-y-3">
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
v-model="selection.components[component.id]"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary mt-1"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<p class="font-medium">{{ component.name }}</p>
|
||||
<p v-if="component.reference" class="text-xs text-base-content/60">
|
||||
{{ component.reference }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div v-if="childPieces.length" class="pl-6 space-y-2">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">
|
||||
Pièces
|
||||
</p>
|
||||
<label
|
||||
v-for="piece in childPieces"
|
||||
:key="piece.id"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<input
|
||||
v-model="selection.pieces[piece.id]"
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-secondary mt-1"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium">{{ piece.name }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{{ piece.reference || 'Référence inconnue' }}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="childComponents.length" class="pl-6 space-y-3 border-l border-base-200">
|
||||
<MachinePrintSelectionNode
|
||||
v-for="child in childComponents"
|
||||
:key="child.id"
|
||||
:component="child"
|
||||
:selection="selection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
defineOptions({ name: 'MachinePrintSelectionNode' })
|
||||
|
||||
const props = defineProps({
|
||||
component: { type: Object, required: true },
|
||||
selection: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const childComponents = computed(() => props.component.subcomponents || props.component.subComponents || [])
|
||||
const childPieces = computed(() => props.component.pieces || [])
|
||||
</script>
|
||||
38
frontend/app/components/ModelStructureViewer.vue
Normal file
38
frontend/app/components/ModelStructureViewer.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-base-content/60">
|
||||
<span v-if="stats.customFields" class="badge badge-outline badge-sm">{{ stats.customFields }} champ(s)</span>
|
||||
<span v-if="stats.pieces" class="badge badge-outline badge-sm">{{ stats.pieces }} pièce(s)</span>
|
||||
<span v-if="stats.subcomponents" class="badge badge-outline badge-sm">{{ stats.subcomponents }} sous-composant(s)</span>
|
||||
<span v-if="!stats.customFields && !stats.pieces && !stats.subcomponents" class="text-xs text-base-content/50">
|
||||
Structure vide
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<details class="collapse collapse-arrow bg-base-200">
|
||||
<summary class="collapse-title text-sm font-medium">
|
||||
Voir la structure JSON
|
||||
</summary>
|
||||
<div class="collapse-content">
|
||||
<pre class="mockup-code whitespace-pre-wrap text-xs bg-base-300 p-4 rounded">
|
||||
<code>{{ formatted }}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computeStructureStats } from '~/shared/modelUtils'
|
||||
|
||||
const props = defineProps({
|
||||
structure: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const stats = computed(() => computeStructureStats(props.structure))
|
||||
const formatted = computed(() => JSON.stringify(props.structure ?? {}, null, 2))
|
||||
</script>
|
||||
81
frontend/app/components/PageHero.vue
Normal file
81
frontend/app/components/PageHero.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<section :class="sectionClasses">
|
||||
<div :class="contentClasses">
|
||||
<div :class="['space-y-3', maxWidthClass]">
|
||||
<component :is="headingTag" v-if="title" class="text-4xl font-bold tracking-tight">
|
||||
{{ title }}
|
||||
</component>
|
||||
<p v-if="subtitle" class="text-sm opacity-80 leading-relaxed">
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
gradientFrom: {
|
||||
type: String,
|
||||
default: 'from-primary'
|
||||
},
|
||||
gradientTo: {
|
||||
type: String,
|
||||
default: 'to-secondary'
|
||||
},
|
||||
minHeight: {
|
||||
type: String,
|
||||
default: 'min-h-[25vh]'
|
||||
},
|
||||
maxWidth: {
|
||||
type: String,
|
||||
default: 'max-w-xl'
|
||||
},
|
||||
rounded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
alignment: {
|
||||
type: String,
|
||||
default: 'center',
|
||||
validator: value => ['center', 'start', 'end'].includes(value)
|
||||
},
|
||||
headingTag: {
|
||||
type: String,
|
||||
default: 'h1'
|
||||
}
|
||||
})
|
||||
|
||||
const sectionClasses = computed(() => {
|
||||
const classes = ['hero', 'bg-gradient-to-br', props.gradientFrom, props.gradientTo, props.minHeight]
|
||||
if (props.rounded) {
|
||||
classes.push('rounded-xl', 'overflow-hidden')
|
||||
}
|
||||
return classes
|
||||
})
|
||||
|
||||
const contentClasses = computed(() => {
|
||||
const base = ['hero-content', 'text-neutral-content']
|
||||
if (props.alignment === 'center') {
|
||||
base.push('text-center')
|
||||
} else if (props.alignment === 'start') {
|
||||
base.push('justify-start', 'text-left')
|
||||
} else if (props.alignment === 'end') {
|
||||
base.push('justify-end', 'text-right')
|
||||
}
|
||||
return base
|
||||
})
|
||||
|
||||
const maxWidthClass = computed(() => props.maxWidth)
|
||||
</script>
|
||||
578
frontend/app/components/PieceItem.vue
Normal file
578
frontend/app/components/PieceItem.vue
Normal file
@@ -0,0 +1,578 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="pieceDocuments"
|
||||
@close="closePreview"
|
||||
/>
|
||||
<DocumentEditModal
|
||||
:visible="editModalVisible"
|
||||
:document="editingDocument"
|
||||
@close="editModalVisible = false"
|
||||
@updated="handleDocumentUpdated"
|
||||
/>
|
||||
|
||||
<!-- 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 gap-3 flex-1 min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle shrink-0 transition-transform"
|
||||
:class="{ 'rotate-90': !isCollapsed }"
|
||||
:aria-expanded="!isCollapsed"
|
||||
:title="isCollapsed ? 'Déplier les détails de la pièce' : 'Replier les détails de la pièce'"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<IconLucideChevronRight class="w-5 h-5 transition-transform" aria-hidden="true" />
|
||||
<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">
|
||||
{{ pieceData.name }}
|
||||
<span
|
||||
v-if="displayQuantity > 1"
|
||||
class="text-sm font-normal text-base-content/60 ml-1"
|
||||
>
|
||||
×{{ displayQuantity }}
|
||||
</span>
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
<span v-if="piece.parentComponentName" class="badge badge-ghost badge-sm">
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
|
||||
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
|
||||
<template v-if="pieceConstructeursDisplay.length">
|
||||
<span
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-xs opacity-60 ml-0.5">
|
||||
({{ supplierReferenceMap.get(constructeur.id) }})
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="pieceData.prix" class="badge badge-primary badge-sm">{{ pieceData.prix }}€</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="showDelete"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error shrink-0"
|
||||
title="Supprimer cette pièce"
|
||||
@click="$emit('delete')"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-show="!isCollapsed" 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">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">Quantité</span>
|
||||
</label>
|
||||
<input
|
||||
v-model.number="pieceData.quantity"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="input input-bordered input-sm md:input-md w-24"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="displayQuantity > 1">
|
||||
<span class="font-medium">Quantité:</span>
|
||||
<span class="ml-2">{{ displayQuantity }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Référence:</span>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="`piece-reference-${piece.id}`"
|
||||
v-model="pieceData.reference"
|
||||
type="text"
|
||||
class="input input-sm input-bordered ml-2"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
<span v-else class="ml-2">{{
|
||||
pieceData.reference || "Non définie"
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="pieceData.referenceAuto">
|
||||
<span class="font-medium">Référence auto:</span>
|
||||
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Fournisseur:</span>
|
||||
<div v-if="!isEditMode" class="ml-2">
|
||||
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
|
||||
<div
|
||||
v-for="constructeur in pieceConstructeursDisplay"
|
||||
:key="constructeur.id"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<span class="font-medium">
|
||||
{{ constructeur.name }}
|
||||
<span v-if="supplierReferenceMap.get(constructeur.id)" class="text-sm font-normal text-base-content/60">
|
||||
— Réf. {{ supplierReferenceMap.get(constructeur.id) }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="formatConstructeurContact(constructeur)"
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
{{ formatConstructeurContact(constructeur) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="font-medium">
|
||||
Non défini
|
||||
</span>
|
||||
</div>
|
||||
<ConstructeurSelect
|
||||
v-else
|
||||
class="w-full"
|
||||
:model-value="pieceConstructeurIds"
|
||||
:initial-options="pieceConstructeursDisplay"
|
||||
placeholder="Sélectionner un ou plusieurs fournisseurs..."
|
||||
@update:model-value="handleConstructeurChange"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Prix:</span>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="`piece-prix-${piece.id}`"
|
||||
v-model="pieceData.prix"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-sm input-bordered ml-2"
|
||||
@blur="updatePiece"
|
||||
/>
|
||||
<span v-else class="ml-2">{{
|
||||
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Produit catalogue:</span>
|
||||
<div v-if="isEditMode" class="mt-2 space-y-2">
|
||||
<ProductSelect
|
||||
:model-value="pieceData.productId"
|
||||
placeholder="Associer un produit…"
|
||||
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
||||
@update:modelValue="handleProductChange"
|
||||
/>
|
||||
<div
|
||||
v-if="selectedProduct"
|
||||
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
|
||||
>
|
||||
<p class="text-sm font-semibold text-base-content">
|
||||
{{ selectedProduct.name }}
|
||||
</p>
|
||||
<p
|
||||
v-for="info in productInfoRows"
|
||||
:key="info.label"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<span class="font-semibold">{{ info.label }} :</span>
|
||||
<span>{{ info.value }}</span>
|
||||
</p>
|
||||
<NuxtLink
|
||||
v-if="selectedProduct.id"
|
||||
:to="`/product/${selectedProduct.id}`"
|
||||
class="link link-primary text-xs"
|
||||
>
|
||||
Ouvrir la fiche produit
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p v-else class="text-xs text-base-content/60">
|
||||
Aucun produit associé.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<div v-if="displayProduct" class="space-y-1">
|
||||
<p class="font-medium text-base-content">
|
||||
{{ displayProductName || 'Produit catalogue' }}
|
||||
</p>
|
||||
<p
|
||||
v-for="info in productInfoRows"
|
||||
:key="info.label"
|
||||
class="text-xs text-base-content/70"
|
||||
>
|
||||
<span class="font-semibold">{{ info.label }} :</span>
|
||||
<span class="ml-1">{{ info.value }}</span>
|
||||
</p>
|
||||
<ProductDocumentsInline
|
||||
:documents="productDocuments"
|
||||
@preview="openPreview"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="font-medium">
|
||||
Non défini
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs personnalisés de la pièce -->
|
||||
<CustomFieldDisplay
|
||||
:fields="displayedCustomFields"
|
||||
:is-edit-mode="isEditMode"
|
||||
@field-input="handleCustomFieldInput"
|
||||
@field-blur="handleCustomFieldBlur"
|
||||
/>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-base-200 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h5 class="text-sm font-medium text-base-content/80">Documents</h5>
|
||||
<span
|
||||
v-if="isEditMode && selectedFiles.length"
|
||||
class="badge badge-outline"
|
||||
>
|
||||
{{ selectedFiles.length }} fichier{{
|
||||
selectedFiles.length > 1 ? "s" : ""
|
||||
}}
|
||||
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="loadingDocuments" class="text-xs text-base-content/50">
|
||||
Chargement des documents...
|
||||
</p>
|
||||
|
||||
<DocumentUpload
|
||||
v-if="isEditMode"
|
||||
v-model="selectedFiles"
|
||||
title="Déposer des fichiers pour cette pièce"
|
||||
subtitle="Formats acceptés : PDF, images, documents..."
|
||||
@files-added="handleFilesAdded"
|
||||
/>
|
||||
|
||||
<DocumentListInline
|
||||
:documents="pieceDocuments"
|
||||
:can-delete="isEditMode"
|
||||
:can-edit="isEditMode"
|
||||
:delete-disabled="uploadingDocuments"
|
||||
empty-text="Aucun document lié à cette pièce."
|
||||
@preview="openPreview"
|
||||
@edit="openEditModal"
|
||||
@delete="removeDocument"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted, watch, computed } from 'vue'
|
||||
import ConstructeurSelect from './ConstructeurSelect.vue'
|
||||
import ProductSelect from '~/components/ProductSelect.vue'
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
uniqueConstructeurIds,
|
||||
parseConstructeurLinksFromApi,
|
||||
} from '~/shared/constructeurUtils'
|
||||
import {
|
||||
resolveFieldId,
|
||||
resolveFieldReadOnly,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
import { useEntityDocuments } from '~/composables/useEntityDocuments'
|
||||
import { useEntityProductDisplay } from '~/composables/useEntityProductDisplay'
|
||||
import { useEntityCustomFields } from '~/composables/useEntityCustomFields'
|
||||
|
||||
const props = defineProps({
|
||||
piece: { type: Object, required: true },
|
||||
isEditMode: { type: Boolean, default: false },
|
||||
showDelete: { type: Boolean, default: false },
|
||||
collapseAll: { type: Boolean, default: true },
|
||||
toggleToken: { type: Number, default: 0 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
|
||||
|
||||
// --- Local reactive data for editing ---
|
||||
const pieceData = reactive({
|
||||
name: props.piece.name || '',
|
||||
reference: props.piece.reference || '',
|
||||
referenceAuto: props.piece.referenceAuto || null,
|
||||
prix: props.piece.prix || '',
|
||||
productId: props.piece.product?.id || props.piece.productId || null,
|
||||
quantity: props.piece.quantity ?? 1,
|
||||
})
|
||||
|
||||
const displayQuantity = computed(() => {
|
||||
return pieceData.quantity ?? 1
|
||||
})
|
||||
|
||||
// --- Products ---
|
||||
const { products, loadProducts, getProduct } = useProducts()
|
||||
|
||||
const selectedProduct = computed(() => {
|
||||
const id = pieceData.productId
|
||||
if (!id) return null
|
||||
const list = Array.isArray(products.value) ? products.value : []
|
||||
const cached = list.find((p) => p && p.id === id) || null
|
||||
if (cached) return cached
|
||||
const current = props.piece.product
|
||||
if (current && current.id === id) return current
|
||||
return null
|
||||
})
|
||||
|
||||
// --- Shared composables ---
|
||||
const {
|
||||
documents: pieceDocuments,
|
||||
selectedFiles,
|
||||
uploadingDocuments,
|
||||
loadingDocuments,
|
||||
previewDocument,
|
||||
previewVisible,
|
||||
openPreview,
|
||||
closePreview,
|
||||
refreshDocuments,
|
||||
handleFilesAdded,
|
||||
removeDocument,
|
||||
editDocument,
|
||||
} = useEntityDocuments({ entity: () => props.piece, entityType: 'piece' })
|
||||
|
||||
const {
|
||||
displayProduct,
|
||||
displayProductName,
|
||||
productInfoRows,
|
||||
productDocuments,
|
||||
} = useEntityProductDisplay({ entity: () => props.piece, selectedProduct })
|
||||
|
||||
const {
|
||||
displayedCustomFields,
|
||||
updateCustomField,
|
||||
} = useEntityCustomFields({ entity: () => props.piece, entityType: 'piece' })
|
||||
|
||||
// --- Document edit modal ---
|
||||
const editingDocument = ref(null)
|
||||
const editModalVisible = ref(false)
|
||||
|
||||
const openEditModal = (doc) => {
|
||||
editingDocument.value = doc
|
||||
editModalVisible.value = true
|
||||
}
|
||||
const handleDocumentUpdated = async (data) => {
|
||||
if (!editingDocument.value?.id) return
|
||||
await editDocument(editingDocument.value.id, data)
|
||||
editModalVisible.value = false
|
||||
editingDocument.value = null
|
||||
}
|
||||
|
||||
// --- Collapse state ---
|
||||
const isCollapsed = ref(true)
|
||||
|
||||
watch(
|
||||
() => props.toggleToken,
|
||||
() => {
|
||||
isCollapsed.value = props.collapseAll
|
||||
if (!isCollapsed.value) refreshDocuments()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
if (!isCollapsed.value) refreshDocuments()
|
||||
}
|
||||
|
||||
// --- Constructeurs ---
|
||||
const { constructeurs } = useConstructeurs()
|
||||
|
||||
const pieceConstructeurLinks = computed(() =>
|
||||
parseConstructeurLinksFromApi(
|
||||
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
||||
),
|
||||
)
|
||||
|
||||
const supplierReferenceMap = computed(() => {
|
||||
const map = new Map()
|
||||
pieceConstructeurLinks.value.forEach(l => {
|
||||
if (l.supplierReference) map.set(l.constructeurId, l.supplierReference)
|
||||
})
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceConstructeurIds = computed(() =>
|
||||
pieceConstructeurLinks.value.map(l => l.constructeurId).filter(Boolean),
|
||||
)
|
||||
|
||||
const pieceConstructeursDisplay = computed(() => {
|
||||
// Extract nested constructeur objects from link entries
|
||||
const linkConstructeurs = pieceConstructeurLinks.value
|
||||
.filter(l => l.constructeur && l.constructeur.id)
|
||||
.map(l => l.constructeur)
|
||||
return resolveConstructeurs(
|
||||
pieceConstructeurIds.value,
|
||||
linkConstructeurs,
|
||||
constructeurs.value,
|
||||
)
|
||||
})
|
||||
|
||||
const formatConstructeurContact = (constructeur) =>
|
||||
formatConstructeurContactSummary(constructeur)
|
||||
|
||||
const handleConstructeurChange = (value) => {
|
||||
const ids = uniqueConstructeurIds(value)
|
||||
props.piece.constructeurIds = [...ids]
|
||||
props.piece.constructeurId = null
|
||||
props.piece.constructeur = null
|
||||
props.piece.constructeurs = resolveConstructeurs(
|
||||
ids,
|
||||
constructeurs.value,
|
||||
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
|
||||
)
|
||||
updatePiece()
|
||||
}
|
||||
|
||||
// --- Product handling ---
|
||||
const ensureProductLoaded = async (id) => {
|
||||
if (!id) return null
|
||||
const list = Array.isArray(products.value) ? products.value : []
|
||||
const cached = list.find((p) => p && p.id === id)
|
||||
if (cached) return cached
|
||||
const result = await getProduct(id, { force: true })
|
||||
return result.success && result.data ? result.data : null
|
||||
}
|
||||
|
||||
const handleProductChange = async (value) => {
|
||||
const nextId = value || null
|
||||
pieceData.productId = nextId
|
||||
props.piece.productId = nextId
|
||||
|
||||
if (!nextId) {
|
||||
props.piece.product = null
|
||||
updatePiece()
|
||||
return
|
||||
}
|
||||
|
||||
const resolved = await ensureProductLoaded(nextId)
|
||||
if (resolved) {
|
||||
props.piece.product = resolved
|
||||
const supplierPrice = resolved.supplierPrice
|
||||
if (
|
||||
(pieceData.prix === '' || pieceData.prix === null || pieceData.prix === undefined) &&
|
||||
supplierPrice !== null && supplierPrice !== undefined
|
||||
) {
|
||||
const number = Number(supplierPrice)
|
||||
if (!Number.isNaN(number)) pieceData.prix = String(number)
|
||||
}
|
||||
}
|
||||
|
||||
updatePiece()
|
||||
}
|
||||
|
||||
// --- Custom field event handlers ---
|
||||
const handleCustomFieldInput = (field, value) => {
|
||||
if (resolveFieldReadOnly(field)) return
|
||||
const fieldValueId = resolveFieldId(field)
|
||||
if (!fieldValueId) return
|
||||
const fieldValue = props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
|
||||
if (fieldValue) fieldValue.value = value
|
||||
}
|
||||
|
||||
const handleCustomFieldBlur = async (field) => {
|
||||
await updateCustomField(field)
|
||||
const cfId = field?.customFieldId || field?.customField?.id || null
|
||||
if (cfId || field?.customFieldValueId) {
|
||||
emit('custom-field-update', {
|
||||
fieldId: cfId,
|
||||
pieceId: props.piece.id,
|
||||
value: field?.value ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update piece ---
|
||||
const updatePiece = () => {
|
||||
const prixValue = pieceData.prix
|
||||
let parsedPrice = null
|
||||
if (prixValue !== null && prixValue !== undefined && String(prixValue).trim().length > 0) {
|
||||
const numeric = Number(prixValue)
|
||||
if (!Number.isNaN(numeric)) parsedPrice = String(numeric)
|
||||
}
|
||||
const product = selectedProduct.value ? { ...selectedProduct.value } : null
|
||||
emit('update', {
|
||||
...props.piece,
|
||||
...pieceData,
|
||||
prix: parsedPrice,
|
||||
quantity: pieceData.quantity ?? 1,
|
||||
productId: pieceData.productId || null,
|
||||
product,
|
||||
constructeurIds: pieceConstructeurIds.value,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Watchers ---
|
||||
watch(
|
||||
() => props.piece.product?.id || props.piece.productId || null,
|
||||
async (id) => {
|
||||
if (pieceData.productId === id) {
|
||||
if (id && !selectedProduct.value) {
|
||||
const resolved = await ensureProductLoaded(id)
|
||||
if (resolved) props.piece.product = resolved
|
||||
}
|
||||
if (!id) props.piece.product = null
|
||||
return
|
||||
}
|
||||
pieceData.productId = id
|
||||
if (id) {
|
||||
const resolved = await ensureProductLoaded(id)
|
||||
if (resolved) {
|
||||
props.piece.product = resolved
|
||||
if (
|
||||
(pieceData.prix === '' || pieceData.prix === null || pieceData.prix === undefined) &&
|
||||
resolved.supplierPrice !== null && resolved.supplierPrice !== undefined
|
||||
) {
|
||||
const number = Number(resolved.supplierPrice)
|
||||
if (!Number.isNaN(number)) pieceData.prix = String(number)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
props.piece.product = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.piece.name, props.piece.reference, props.piece.prix, props.piece.quantity],
|
||||
() => {
|
||||
pieceData.name = props.piece.name || ''
|
||||
pieceData.reference = props.piece.reference || ''
|
||||
pieceData.prix = props.piece.prix || ''
|
||||
pieceData.quantity = props.piece.quantity ?? 1
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
pieceData.name = props.piece.name || ''
|
||||
pieceData.reference = props.piece.reference || ''
|
||||
pieceData.prix = props.piece.prix || ''
|
||||
pieceData.quantity = props.piece.quantity ?? 1
|
||||
loadProducts().catch(() => {})
|
||||
if (pieceData.productId) ensureProductLoaded(pieceData.productId)
|
||||
if (!props.piece.documents?.length) refreshDocuments()
|
||||
})
|
||||
</script>
|
||||
186
frontend/app/components/PieceModelStructureEditor.vue
Normal file
186
frontend/app/components/PieceModelStructureEditor.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="space-y-3">
|
||||
<header>
|
||||
<h3 class="text-sm font-semibold">
|
||||
Produits inclus par défaut
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p v-if="!products.length" class="text-xs text-base-content/50">
|
||||
Aucun produit défini.
|
||||
</p>
|
||||
|
||||
<ul v-else class="space-y-2" role="list">
|
||||
<li
|
||||
v-for="(product, index) in products"
|
||||
:key="product.uid"
|
||||
class="space-y-3 rounded-md border border-base-200 bg-base-100 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-xs">Famille de produit</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner une famille
|
||||
</option>
|
||||
<option
|
||||
v-for="type in productTypeOptions"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ formatProductTypeOption(type) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeProduct(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Champs personnalisés
|
||||
</h3>
|
||||
|
||||
<p v-if="!fields.length" class="text-xs text-base-content/50">
|
||||
Aucun champ personnalisé n'a encore été défini.
|
||||
</p>
|
||||
|
||||
<ul v-else class="space-y-2" role="list">
|
||||
<li
|
||||
v-for="(field, index) in fields"
|
||||
:key="field.uid"
|
||||
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
|
||||
:class="reorderClass(index)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(index, $event)"
|
||||
@dragenter="onDragEnter(index)"
|
||||
@dragover.prevent="onDragEnter(index)"
|
||||
@drop.prevent="onDrop(index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
|
||||
title="Réordonner"
|
||||
draggable="false"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
<option value="number">
|
||||
Nombre
|
||||
</option>
|
||||
<option value="select">
|
||||
Liste
|
||||
</option>
|
||||
<option value="boolean">
|
||||
Oui/Non
|
||||
</option>
|
||||
<option value="date">
|
||||
Date
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addField">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import type { PieceModelStructure } from '~/shared/types/inventory'
|
||||
import { usePieceStructureEditorLogic } from '~/composables/usePieceStructureEditorLogic'
|
||||
|
||||
defineOptions({ name: 'PieceModelStructureEditor' })
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: PieceModelStructure | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: PieceModelStructure): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
fields,
|
||||
products,
|
||||
productTypeOptions,
|
||||
formatProductTypeOption,
|
||||
handleProductTypeSelect,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
addField,
|
||||
removeField,
|
||||
reorderClass,
|
||||
onDragStart,
|
||||
onDragEnter,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
} = usePieceStructureEditorLogic({ props, emit })
|
||||
</script>
|
||||
130
frontend/app/components/PieceSelect.vue
Normal file
130
frontend/app/components/PieceSelect.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<SearchSelect
|
||||
:model-value="modelValue ?? undefined"
|
||||
:options="pieceOptions"
|
||||
:loading="loading"
|
||||
:placeholder="placeholder"
|
||||
:empty-text="emptyText"
|
||||
size="sm"
|
||||
option-value="id"
|
||||
:option-label="formatLabel"
|
||||
:disabled="disabled"
|
||||
server-search
|
||||
@update:modelValue="updateValue"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #option-description="{ option }">
|
||||
<span class="text-xs text-base-content/60">
|
||||
{{ formatDescription(option) }}
|
||||
</span>
|
||||
</template>
|
||||
</SearchSelect>
|
||||
<p v-if="helperText" class="text-xs text-base-content/60">
|
||||
{{ helperText }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
helperText?: string
|
||||
disabled?: boolean
|
||||
typePieceId?: string | null
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: 'Sélectionner une pièce…',
|
||||
emptyText: 'Aucune pièce disponible',
|
||||
helperText: '',
|
||||
disabled: false,
|
||||
typePieceId: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { loading: globalLoading, loadPieces } = usePieces()
|
||||
|
||||
const localPieces = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
const pieceOptions = computed(() => localPieces.value)
|
||||
|
||||
const loadFilteredPieces = async (search = '') => {
|
||||
if (!props.typePieceId) return
|
||||
localLoading.value = true
|
||||
try {
|
||||
const result = await loadPieces({ typePieceId: props.typePieceId, search, itemsPerPage: 200, force: true })
|
||||
if (result.success && result.data?.items) {
|
||||
localPieces.value = result.data.items
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
const handleSearch = (term: string) => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => loadFilteredPieces(term.trim()), 300)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFilteredPieces()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.typePieceId,
|
||||
() => {
|
||||
loadFilteredPieces()
|
||||
},
|
||||
)
|
||||
|
||||
const updateValue = (value: string | number | null | undefined) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', String(value))
|
||||
}
|
||||
|
||||
const formatLabel = (option: any) => {
|
||||
if (!option) return ''
|
||||
const name = option.name || 'Pièce'
|
||||
return option.reference ? `${name} — ${option.reference}` : name
|
||||
}
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typePiece?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.prix !== undefined && option.prix !== null) {
|
||||
const price = Number(option.prix)
|
||||
if (!Number.isNaN(price)) {
|
||||
parts.push(`${price.toFixed(2)} €`)
|
||||
}
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Sans référence'
|
||||
}
|
||||
</script>
|
||||
78
frontend/app/components/ProductDocumentsInline.vue
Normal file
78
frontend/app/components/ProductDocumentsInline.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="documents.length"
|
||||
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
|
||||
>
|
||||
<h5 class="font-medium text-base-content">Documents du produit</h5>
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.id || document.path || document.name"
|
||||
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(document)"
|
||||
:src="documentPreviewSrc(document)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(document).component"
|
||||
class="h-5 w-5"
|
||||
:class="documentIcon(document).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-base-content">
|
||||
{{ document.name }}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/70">
|
||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(document)"
|
||||
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="$emit('preview', document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps({
|
||||
documents: { type: Array, required: true },
|
||||
})
|
||||
|
||||
defineEmits(['preview'])
|
||||
</script>
|
||||
130
frontend/app/components/ProductSelect.vue
Normal file
130
frontend/app/components/ProductSelect.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<SearchSelect
|
||||
:model-value="modelValue ?? undefined"
|
||||
:options="productOptions"
|
||||
:loading="loading"
|
||||
:placeholder="placeholder"
|
||||
:empty-text="emptyText"
|
||||
size="sm"
|
||||
option-value="id"
|
||||
:option-label="formatLabel"
|
||||
:disabled="disabled"
|
||||
server-search
|
||||
@update:modelValue="updateValue"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<template #option-description="{ option }">
|
||||
<span class="text-xs text-base-content/60">
|
||||
{{ formatDescription(option) }}
|
||||
</span>
|
||||
</template>
|
||||
</SearchSelect>
|
||||
<p v-if="helperText" class="text-xs text-base-content/60">
|
||||
{{ helperText }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string | null
|
||||
placeholder?: string
|
||||
emptyText?: string
|
||||
helperText?: string
|
||||
disabled?: boolean
|
||||
typeProductId?: string | null
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
placeholder: 'Sélectionner un produit…',
|
||||
emptyText: 'Aucun produit disponible',
|
||||
helperText: '',
|
||||
disabled: false,
|
||||
typeProductId: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const { loading: globalLoading, loadProducts } = useProducts()
|
||||
|
||||
const localProducts = ref<any[]>([])
|
||||
const localLoading = ref(false)
|
||||
const loading = computed(() => localLoading.value || globalLoading.value)
|
||||
|
||||
const productOptions = computed(() => localProducts.value)
|
||||
|
||||
const loadFilteredProducts = async (search = '') => {
|
||||
if (!props.typeProductId) return
|
||||
localLoading.value = true
|
||||
try {
|
||||
const result = await loadProducts({ typeProductId: props.typeProductId, search, itemsPerPage: 200, force: true })
|
||||
if (result.success && result.data?.items) {
|
||||
localProducts.value = result.data.items
|
||||
}
|
||||
}
|
||||
catch (error: unknown) {
|
||||
console.error('Erreur lors du chargement des produits:', error)
|
||||
}
|
||||
finally {
|
||||
localLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
const handleSearch = (term: string) => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => loadFilteredProducts(term.trim()), 300)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFilteredProducts()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.typeProductId,
|
||||
() => {
|
||||
loadFilteredProducts()
|
||||
},
|
||||
)
|
||||
|
||||
const updateValue = (value: string | number | null | undefined) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', String(value))
|
||||
}
|
||||
|
||||
const formatLabel = (option: any) => {
|
||||
if (!option) return ''
|
||||
const name = option.name || 'Produit'
|
||||
return option.reference ? `${name} — ${option.reference}` : name
|
||||
}
|
||||
|
||||
const formatDescription = (option: any) => {
|
||||
const parts: string[] = []
|
||||
const typeName = option?.typeProduct?.name
|
||||
if (typeName) {
|
||||
parts.push(typeName)
|
||||
}
|
||||
if (option?.reference) {
|
||||
parts.push(`Ref. ${option.reference}`)
|
||||
}
|
||||
if (option?.supplierPrice !== undefined && option.supplierPrice !== null) {
|
||||
const price = Number(option.supplierPrice)
|
||||
if (!Number.isNaN(price)) {
|
||||
parts.push(`${price.toFixed(2)} €`)
|
||||
}
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Sans référence'
|
||||
}
|
||||
</script>
|
||||
416
frontend/app/components/StructureNodeEditor.vue
Normal file
416
frontend/app/components/StructureNodeEditor.vue
Normal file
@@ -0,0 +1,416 @@
|
||||
<template>
|
||||
<div :class="containerClass">
|
||||
<div class="border border-base-200 rounded-lg bg-base-100 shadow-sm">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-base-200 px-4 py-3">
|
||||
<div class="flex-1 min-w-[220px] space-y-2">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-semibold">
|
||||
{{ isRoot ? 'Composant racine de la catégorie' : 'Famille de composant' }}
|
||||
</span>
|
||||
</label>
|
||||
<template v-if="isRoot">
|
||||
<p class="text-[11px] text-base-content/50">
|
||||
Le composant racine correspond à la catégorie que vous éditez. Sélectionnez uniquement les familles pour les sous-composants.
|
||||
</p>
|
||||
</template>
|
||||
<template v-else-if="!lockType">
|
||||
<select
|
||||
v-model="node.typeComposantId"
|
||||
class="select select-bordered select-sm w-full"
|
||||
:disabled="isLocked"
|
||||
@change="handleComponentTypeSelect(node)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner une famille de composant
|
||||
</option>
|
||||
<option
|
||||
v-for="type in componentTypes"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ formatComponentTypeOption(type) }}
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-[11px] text-base-content/50">
|
||||
{{ node.typeComposantId ? `Sélection : ${getComponentTypeLabel(node.typeComposantId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||
</p>
|
||||
<div v-if="!isRoot" class="form-control mt-2">
|
||||
<label class="label py-1">
|
||||
<span class="label-text text-[11px]">Alias (optionnel)</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="node.alias"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Alias du sous-composant"
|
||||
:disabled="isLocked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200 flex items-center">
|
||||
{{ lockedTypeDisplay }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isRoot && !isLocked"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else-if="!isRoot && isLocked" class="tooltip tooltip-left" data-tip="Ce sous-composant ne peut pas être supprimé">
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 space-y-5">
|
||||
<section v-if="isRoot" class="space-y-3">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Champs personnalisés du composant' : 'Champs personnalisés' }}
|
||||
</h4>
|
||||
<p v-if="!(node.customFields?.length)" class="text-xs text-base-content/50">
|
||||
Aucun champ n'a encore été défini.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(field, index) in node.customFields"
|
||||
:key="`field-${index}`"
|
||||
class="border border-base-200 rounded-md p-3 space-y-2 transition-colors"
|
||||
:class="customFieldReorderClass(index)"
|
||||
draggable="true"
|
||||
@dragstart="onCustomFieldDragStart(index, $event)"
|
||||
@dragenter="onCustomFieldDragEnter(index)"
|
||||
@dragover.prevent
|
||||
@drop="onCustomFieldDrop(index)"
|
||||
@dragend="onCustomFieldDragEnd"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
title="Réorganiser"
|
||||
draggable="true"
|
||||
@dragstart.stop="onCustomFieldDragStart(index, $event)"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<option value="text">Texte</option>
|
||||
<option value="number">Nombre</option>
|
||||
<option value="select">Liste</option>
|
||||
<option value="boolean">Oui/Non</option>
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
|
||||
Obligatoire
|
||||
</div>
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeCustomField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addCustomField">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="isRoot" class="space-y-3">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||
</h4>
|
||||
<p v-if="!(node.products?.length)" class="text-xs text-base-content/50">
|
||||
Aucun produit défini.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(product, index) in node.products"
|
||||
:key="`product-${index}`"
|
||||
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
|
||||
:class="productReorderClass(index)"
|
||||
@dragenter="onProductDragEnter(index)"
|
||||
@dragover="onProductDragOver"
|
||||
@drop="onProductDrop(index)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
title="Réorganiser"
|
||||
@dragstart="onProductDragStart(index, $event)"
|
||||
@dragend="onProductDragEnd"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label py-1"><span class="label-text text-xs">Famille de produit</span></label>
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner une famille
|
||||
</option>
|
||||
<option
|
||||
v-for="type in productTypes"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ formatProductTypeOption(type) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="isRoot" class="space-y-3">
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||
</h4>
|
||||
<p v-if="!(node.pieces?.length)" class="text-xs text-base-content/50">
|
||||
Aucune pièce définie.
|
||||
</p>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(piece, index) in node.pieces"
|
||||
:key="`piece-${index}`"
|
||||
class="relative border border-base-200 rounded-md p-3 pl-10 space-y-3 transition-colors"
|
||||
:class="pieceReorderClass(index)"
|
||||
@dragenter="onPieceDragEnter(index)"
|
||||
@dragover="onPieceDragOver"
|
||||
@drop="onPieceDrop(index)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-3 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
title="Réorganiser"
|
||||
@dragstart="onPieceDragStart(index, $event)"
|
||||
@dragend="onPieceDragEnd"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 space-y-3">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Famille de pièce</span></label>
|
||||
<div>
|
||||
<select
|
||||
v-model="piece.typePieceId"
|
||||
class="select select-bordered select-xs"
|
||||
@change="handlePieceTypeSelect(piece)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner une famille
|
||||
</option>
|
||||
<option
|
||||
v-for="type in pieceTypes"
|
||||
:key="type.id"
|
||||
:value="type.id"
|
||||
>
|
||||
{{ formatPieceTypeOption(type) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="mt-1 text-[11px] text-base-content/50">
|
||||
{{ piece.typePieceId ? `Sélection : ${getPieceTypeLabel(piece.typePieceId) || 'Inconnue'}` : 'Aucune famille sélectionnée' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Quantity is set per-component on the component edit page -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-if="canManageSubcomponents || hasSubcomponents" class="space-y-3">
|
||||
<h4 :class="headingClass">Sous-composants</h4>
|
||||
<p v-if="!isRoot && canManageSubcomponents" class="text-[11px] text-base-content/50">
|
||||
Sélectionnez uniquement la famille de ce sous-composant ; il sera configuré via son propre modèle.
|
||||
</p>
|
||||
<p v-if="!hasSubcomponents" class="text-xs text-base-content/50">
|
||||
Aucun sous-composant défini.
|
||||
</p>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(subComponent, index) in node.subcomponents"
|
||||
:key="`sub-${index}`"
|
||||
class="relative pl-8 transition-shadow rounded-lg"
|
||||
:class="subcomponentReorderClass(index)"
|
||||
@dragenter="onSubcomponentDragEnter(index)"
|
||||
@dragover="onSubcomponentDragOver"
|
||||
@drop="onSubcomponentDrop(index)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-0 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
title="Réorganiser"
|
||||
@dragstart="onSubcomponentDragStart(index, $event)"
|
||||
@dragend="onSubcomponentDragEnd"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<StructureNodeEditor
|
||||
:node="subComponent"
|
||||
:depth="depth + 1"
|
||||
:component-types="componentTypes"
|
||||
:piece-types="pieceTypes"
|
||||
:product-types="productTypes"
|
||||
:allow-subcomponents="childAllowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
@remove="removeSubComponent(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canManageSubcomponents"
|
||||
type="button"
|
||||
class="btn btn-outline btn-xs"
|
||||
@click="addSubComponent"
|
||||
>
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
import type { EditableStructureNode, ModelTypeOption } from '~/composables/useStructureNodeLogic'
|
||||
|
||||
defineOptions({ name: 'StructureNodeEditor' })
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
node: EditableStructureNode
|
||||
depth?: number
|
||||
componentTypes?: ModelTypeOption[]
|
||||
pieceTypes?: ModelTypeOption[]
|
||||
productTypes?: ModelTypeOption[]
|
||||
isRoot?: boolean
|
||||
lockType?: boolean
|
||||
lockedTypeLabel?: string
|
||||
allowSubcomponents?: boolean
|
||||
maxSubcomponentDepth?: number
|
||||
isLocked?: boolean
|
||||
}>(), {
|
||||
depth: 0,
|
||||
componentTypes: () => [],
|
||||
pieceTypes: () => [],
|
||||
productTypes: () => [],
|
||||
isRoot: false,
|
||||
lockType: false,
|
||||
lockedTypeLabel: '',
|
||||
allowSubcomponents: true,
|
||||
maxSubcomponentDepth: Infinity,
|
||||
isLocked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
const {
|
||||
isLocked,
|
||||
componentTypes,
|
||||
pieceTypes,
|
||||
productTypes,
|
||||
canManageSubcomponents,
|
||||
childAllowSubcomponents,
|
||||
hasSubcomponents,
|
||||
containerClass,
|
||||
headingClass,
|
||||
lockedTypeDisplay,
|
||||
getComponentTypeLabel,
|
||||
getPieceTypeLabel,
|
||||
formatComponentTypeOption,
|
||||
formatPieceTypeOption,
|
||||
formatProductTypeOption,
|
||||
handleComponentTypeSelect,
|
||||
handlePieceTypeSelect,
|
||||
handleProductTypeSelect,
|
||||
addCustomField,
|
||||
removeCustomField,
|
||||
addPiece,
|
||||
removePiece,
|
||||
addProduct,
|
||||
removeProduct,
|
||||
addSubComponent,
|
||||
removeSubComponent,
|
||||
onCustomFieldDragStart,
|
||||
onCustomFieldDragEnter,
|
||||
onCustomFieldDrop,
|
||||
onCustomFieldDragEnd,
|
||||
customFieldReorderClass,
|
||||
onPieceDragStart,
|
||||
onPieceDragEnter,
|
||||
onPieceDragOver,
|
||||
onPieceDrop,
|
||||
onPieceDragEnd,
|
||||
pieceReorderClass,
|
||||
onProductDragStart,
|
||||
onProductDragEnter,
|
||||
onProductDragOver,
|
||||
onProductDrop,
|
||||
onProductDragEnd,
|
||||
productReorderClass,
|
||||
onSubcomponentDragStart,
|
||||
onSubcomponentDragEnter,
|
||||
onSubcomponentDragOver,
|
||||
onSubcomponentDrop,
|
||||
onSubcomponentDragEnd,
|
||||
subcomponentReorderClass,
|
||||
} = useStructureNodeLogic(props)
|
||||
</script>
|
||||
112
frontend/app/components/SyncConfirmationModal.vue
Normal file
112
frontend/app/components/SyncConfirmationModal.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import type { SyncPreviewResult } from '~/services/modelTypes';
|
||||
|
||||
const props = defineProps<{
|
||||
preview: SyncPreviewResult | null;
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: [];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement>();
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen) {
|
||||
dialogRef.value?.showModal();
|
||||
}
|
||||
else {
|
||||
dialogRef.value?.close();
|
||||
}
|
||||
});
|
||||
|
||||
const hasDeletions = computed(() => {
|
||||
if (!props.preview) return false;
|
||||
return Object.values(props.preview.deletions).some(v => v > 0);
|
||||
});
|
||||
|
||||
const hasModifications = computed(() => {
|
||||
if (!props.preview) return false;
|
||||
return Object.values(props.preview.modifications).some(v => v > 0);
|
||||
});
|
||||
|
||||
const totalAdditions = computed(() => {
|
||||
if (!props.preview) return 0;
|
||||
return Object.values(props.preview.additions).reduce((sum, v) => sum + v, 0);
|
||||
});
|
||||
|
||||
const totalDeletions = computed(() => {
|
||||
if (!props.preview) return 0;
|
||||
return Object.values(props.preview.deletions).reduce((sum, v) => sum + v, 0);
|
||||
});
|
||||
|
||||
const totalModifications = computed(() => {
|
||||
if (!props.preview) return 0;
|
||||
return Object.values(props.preview.modifications).reduce((sum, v) => sum + v, 0);
|
||||
});
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel');
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
emit('confirm');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog ref="dialogRef" class="modal" @close="handleCancel">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">
|
||||
Synchronisation des éléments liés
|
||||
</h3>
|
||||
|
||||
<div v-if="preview" class="py-4 space-y-3">
|
||||
<p>
|
||||
Cette modification impactera
|
||||
<strong>{{ preview.itemCount }}</strong>
|
||||
élément(s) lié(s).
|
||||
</p>
|
||||
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li v-if="totalAdditions > 0" class="text-success">
|
||||
{{ totalAdditions }} ajout(s)
|
||||
</li>
|
||||
<li v-if="totalDeletions > 0" class="text-error">
|
||||
{{ totalDeletions }} suppression(s)
|
||||
</li>
|
||||
<li v-if="totalModifications > 0" class="text-warning">
|
||||
{{ totalModifications }} modification(s)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="hasDeletions" role="alert" class="alert alert-warning">
|
||||
<span>Des éléments seront supprimés. Cette action est irréversible.</span>
|
||||
</div>
|
||||
|
||||
<div v-if="hasModifications" role="alert" class="alert alert-info">
|
||||
<span>Des valeurs de champs personnalisés seront réinitialisées.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost" :disabled="loading" @click="handleCancel">
|
||||
Annuler
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="loading" @click="handleConfirm">
|
||||
<span v-if="loading" class="loading loading-spinner loading-sm" />
|
||||
Confirmer la synchronisation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button @click="handleCancel">
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
114
frontend/app/components/ToastContainer.vue
Normal file
114
frontend/app/components/ToastContainer.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div
|
||||
class="toast-container pointer-events-none fixed bottom-4 right-4 z-50 flex flex-col gap-2 items-end"
|
||||
>
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast-item"
|
||||
:class="[
|
||||
'transform transition-all duration-300 ease-in-out',
|
||||
toast.visible ? 'translate-y-0 opacity-100 pointer-events-auto' : 'translate-y-4 opacity-0 pointer-events-none'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="alert toast-card shadow-md px-3 py-2 text-sm"
|
||||
:class="getToastClasses(toast.type)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Icon -->
|
||||
<div class="flex-shrink-0">
|
||||
<IconLucideCheck
|
||||
v-if="toast.type === 'success'"
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideCircleX
|
||||
v-else-if="toast.type === 'error'"
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideAlertTriangle
|
||||
v-else-if="toast.type === 'warning'"
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideInfo
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div class="flex-1">
|
||||
<span class="font-medium">{{ toast.message }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button
|
||||
class="btn btn-ghost btn-2xs"
|
||||
@click="removeToast(toast.id)"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import IconLucideCheck from '~icons/lucide/check'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
import IconLucideCircleX from '~icons/lucide/circle-x'
|
||||
import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||
import IconLucideInfo from '~icons/lucide/info'
|
||||
|
||||
const { toasts, removeToast } = useToast()
|
||||
|
||||
const getToastClasses = (type) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'alert-success text-success-content'
|
||||
case 'error':
|
||||
return 'alert-error text-error-content'
|
||||
case 'warning':
|
||||
return 'alert-warning text-warning-content'
|
||||
case 'info':
|
||||
return 'alert-info text-info-content'
|
||||
default:
|
||||
return 'alert-info'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
|
||||
.toast-move {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.toast-card {
|
||||
max-width: 20rem;
|
||||
pointer-events: auto;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
41
frontend/app/components/common/ConfirmModal.vue
Normal file
41
frontend/app/components/common/ConfirmModal.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="confirmState.open"
|
||||
class="fixed inset-0 z-[1200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click.self="handleCancel"
|
||||
>
|
||||
<div class="bg-base-100 rounded-box shadow-xl w-full max-w-md mx-4 p-6 space-y-4">
|
||||
<h3 class="font-bold text-lg">
|
||||
{{ confirmState.title }}
|
||||
</h3>
|
||||
|
||||
<p class="whitespace-pre-line text-base-content/80">
|
||||
{{ confirmState.message }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ confirmState.cancelText }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
:class="confirmState.dangerous ? 'btn-error' : 'btn-primary'"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
{{ confirmState.confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
|
||||
const { confirmState, handleConfirm, handleCancel } = useConfirm()
|
||||
</script>
|
||||
173
frontend/app/components/common/CustomFieldDisplay.vue
Normal file
173
frontend/app/components/common/CustomFieldDisplay.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="fields.length"
|
||||
class="mt-4 pt-4 border-t border-base-200"
|
||||
>
|
||||
<h5 class="text-sm font-medium text-base-content/80 mb-3">
|
||||
Champs personnalisés
|
||||
</h5>
|
||||
<div :class="layoutClass">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="resolveFieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{
|
||||
resolveFieldName(field)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="resolveFieldRequired(field)"
|
||||
class="label-text-alt text-error"
|
||||
>*</span>
|
||||
</label>
|
||||
|
||||
<!-- Mode édition -->
|
||||
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
|
||||
<!-- Champ de type TEXT -->
|
||||
<input
|
||||
v-if="resolveFieldType(field) === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type NUMBER -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type SELECT -->
|
||||
<select
|
||||
v-else-if="resolveFieldType(field) === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@change="onInput(field, ($event.target as HTMLSelectElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner...
|
||||
</option>
|
||||
<option
|
||||
v-for="option in resolveFieldOptions(field)"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- Champ de type BOOLEAN -->
|
||||
<div
|
||||
v-else-if="resolveFieldType(field) === 'boolean'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="onBooleanChange(field, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span class="text-sm">{{
|
||||
String(field.value).toLowerCase() === "true" ? "Oui" : "Non"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- Champ de type DATE -->
|
||||
<input
|
||||
v-else-if="resolveFieldType(field) === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
|
||||
<!-- Champ de type TEXTAREA -->
|
||||
<textarea
|
||||
v-else-if="resolveFieldType(field) === 'textarea'"
|
||||
:value="field.value ?? ''"
|
||||
class="textarea textarea-bordered textarea-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
/>
|
||||
|
||||
<!-- Fallback: input text -->
|
||||
<input
|
||||
v-else
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="resolveFieldRequired(field)"
|
||||
@input="onInput(field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="onBlur(field)"
|
||||
>
|
||||
</template>
|
||||
|
||||
<!-- Mode lecture seule -->
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatFieldDisplayValue(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
resolveFieldKey,
|
||||
resolveFieldName,
|
||||
resolveFieldType,
|
||||
resolveFieldOptions,
|
||||
resolveFieldRequired,
|
||||
resolveFieldReadOnly,
|
||||
formatFieldDisplayValue,
|
||||
} from '~/shared/utils/entityCustomFieldLogic'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: any[]
|
||||
isEditMode: boolean
|
||||
columns?: 1 | 2
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'field-input': [field: any, value: string]
|
||||
'field-blur': [field: any]
|
||||
}>()
|
||||
|
||||
const layoutClass = computed(() =>
|
||||
props.columns === 2
|
||||
? 'grid grid-cols-1 md:grid-cols-2 gap-4'
|
||||
: 'space-y-3',
|
||||
)
|
||||
|
||||
function onInput(field: any, value: string) {
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
}
|
||||
|
||||
function onBooleanChange(field: any, checked: boolean) {
|
||||
const value = checked ? 'true' : 'false'
|
||||
field.value = value
|
||||
emit('field-input', field, value)
|
||||
emit('field-blur', field)
|
||||
}
|
||||
|
||||
function onBlur(field: any) {
|
||||
emit('field-blur', field)
|
||||
}
|
||||
</script>
|
||||
83
frontend/app/components/common/CustomFieldInputGrid.vue
Normal file
83
frontend/app/components/common/CustomFieldInputGrid.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div
|
||||
v-for="(field, index) in fields"
|
||||
:key="fieldKey(field, index)"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
v-model="field.value"
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="field.value"
|
||||
class="select select-bordered select-sm md:select-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="field.value"
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm md:toggle-md"
|
||||
true-value="true"
|
||||
false-value="false"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="text-sm" :class="field.value === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ field.value === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="field.value"
|
||||
type="date"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<input
|
||||
v-else
|
||||
v-model="field.value"
|
||||
type="text"
|
||||
class="input input-bordered input-sm md:input-md"
|
||||
:required="field.required"
|
||||
:disabled="disabled"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fieldKey, type CustomFieldInput } from '~/shared/utils/customFieldFormUtils'
|
||||
|
||||
defineProps<{
|
||||
fields: CustomFieldInput[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
308
frontend/app/components/common/DataTable.vue
Normal file
308
frontend/app/components/common/DataTable.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- Toolbar + counter row -->
|
||||
<div
|
||||
v-if="$slots.toolbar || showCounter || showPerPage"
|
||||
class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<slot name="toolbar" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="showPerPage && pagination?.perPageOptions?.length" class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="dt-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="dt-per-page"
|
||||
:value="pagination.perPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
|
||||
>
|
||||
<option v-for="opt in pagination.perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<p v-if="showCounter && pagination" class="text-xs text-base-content/50 whitespace-nowrap">
|
||||
{{ pagination.pageItems }} / {{ pagination.totalItems }}
|
||||
résultat{{ pagination.totalItems > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state (full spinner only when no filterable columns to keep visible) -->
|
||||
<div v-if="loading && !hasFilterableColumns" class="flex justify-center py-8">
|
||||
<slot name="loading">
|
||||
<span class="loading loading-spinner" aria-hidden="true" />
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (no data at all, no filterable columns to keep visible) -->
|
||||
<template v-else-if="isEmpty && !hasFilterableColumns">
|
||||
<slot name="empty">
|
||||
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||
{{ emptyMessage }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- No results without filterable columns -->
|
||||
<template v-else-if="rows.length === 0 && !hasFilterableColumns">
|
||||
<slot name="no-results">
|
||||
<p class="text-sm text-base-content/70 py-8 text-center">
|
||||
{{ noResultsMessage }}
|
||||
</p>
|
||||
</slot>
|
||||
</template>
|
||||
|
||||
<!-- Table (always shown when there are filterable columns, even during loading or with 0 rows) -->
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto overflow-y-clip relative rounded-lg border border-base-300/40">
|
||||
<!-- Loading overlay (keeps table & filter inputs visible) -->
|
||||
<div
|
||||
v-if="loading && hasFilterableColumns"
|
||||
class="absolute inset-0 bg-base-100/60 backdrop-blur-[1px] z-10 flex items-center justify-center"
|
||||
>
|
||||
<span class="loading loading-spinner text-primary" aria-hidden="true" />
|
||||
</div>
|
||||
<table :class="['table table-sm md:table-md', tableClass]">
|
||||
<thead>
|
||||
<!-- Header labels + sort -->
|
||||
<tr>
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
col.width,
|
||||
col.class,
|
||||
col.headerClass,
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
>
|
||||
<slot :name="`header-${col.key}`" :column="col">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center gap-1',
|
||||
col.sortable ? 'cursor-pointer select-none hover:text-base-content' : '',
|
||||
]"
|
||||
@click="col.sortable && handleHeaderSort(col)"
|
||||
>
|
||||
{{ col.label }}
|
||||
<template v-if="col.sortable">
|
||||
<IconLucideChevronUp
|
||||
v-if="isSortedAsc(col)"
|
||||
class="h-3.5 w-3.5"
|
||||
aria-label="Trié croissant"
|
||||
/>
|
||||
<IconLucideChevronDown
|
||||
v-else-if="isSortedDesc(col)"
|
||||
class="h-3.5 w-3.5"
|
||||
aria-label="Trié décroissant"
|
||||
/>
|
||||
<IconLucideChevronsUpDown
|
||||
v-else
|
||||
class="h-3.5 w-3.5 opacity-30"
|
||||
aria-label="Triable"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
<th v-if="expandable" class="w-12" />
|
||||
</tr>
|
||||
<!-- Filter inputs row -->
|
||||
<tr v-if="hasFilterableColumns">
|
||||
<th
|
||||
v-for="col in columns"
|
||||
:key="`filter-${col.key}`"
|
||||
class="p-1"
|
||||
:class="{ 'hidden sm:table-cell': col.hiddenMobile }"
|
||||
>
|
||||
<input
|
||||
v-if="col.filterable"
|
||||
type="text"
|
||||
class="input input-bordered input-xs w-full"
|
||||
:placeholder="col.filterPlaceholder || 'Filtrer…'"
|
||||
:value="columnFilters[col.key] ?? ''"
|
||||
@input="handleFilterInput(col.key, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</th>
|
||||
<th v-if="expandable" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- No results message (inside table to keep headers visible) -->
|
||||
<tr v-if="rows.length === 0">
|
||||
<td :colspan="expandable ? columns.length + 1 : columns.length" class="text-center py-8">
|
||||
<p class="text-sm text-base-content/70">
|
||||
{{ isEmpty ? emptyMessage : noResultsMessage }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-for="(row, idx) in rows" :key="getRowKey(row)">
|
||||
<tr>
|
||||
<td
|
||||
v-for="col in columns"
|
||||
:key="col.key"
|
||||
:class="[
|
||||
col.class,
|
||||
alignClass(col),
|
||||
{ 'hidden sm:table-cell': col.hiddenMobile },
|
||||
]"
|
||||
>
|
||||
<slot :name="`cell-${col.key}`" :row="row" :column="col" :index="idx">
|
||||
{{ row[col.key] ?? '—' }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="expandable" class="text-center">
|
||||
<button
|
||||
v-if="!canExpand || canExpand(row)"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="emit('toggle-expand', getRowKey(row))"
|
||||
>
|
||||
{{ isExpanded(row) ? 'Masquer' : 'Voir' }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-base-content/50">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Expanded row -->
|
||||
<tr v-if="expandable && isExpanded(row)">
|
||||
<td :colspan="columns.length + 1" class="bg-base-200/30 p-4 border-t border-base-200/80">
|
||||
<slot name="row-expanded" :row="row" :index="idx" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination
|
||||
v-if="pagination && pagination.totalPages > 1"
|
||||
:current-page="pagination.currentPage"
|
||||
:total-pages="pagination.totalPages"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataTableColumn, DataTableSort, DataTablePagination, DataTableColumnFilters } from '~/shared/types/dataTable'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import IconLucideChevronUp from '~icons/lucide/chevron-up'
|
||||
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
columns: DataTableColumn[]
|
||||
rows: any[]
|
||||
rowKey?: string
|
||||
loading?: boolean
|
||||
sort?: DataTableSort | null
|
||||
pagination?: DataTablePagination | null
|
||||
columnFilters?: DataTableColumnFilters
|
||||
emptyMessage?: string
|
||||
noResultsMessage?: string
|
||||
expandable?: boolean
|
||||
expandedKeys?: Set<string>
|
||||
canExpand?: (row: any) => boolean
|
||||
tableClass?: string
|
||||
showCounter?: boolean
|
||||
showPerPage?: boolean
|
||||
}>(), {
|
||||
rowKey: 'id',
|
||||
loading: false,
|
||||
sort: null,
|
||||
pagination: null,
|
||||
columnFilters: () => ({}),
|
||||
emptyMessage: 'Aucune donnée disponible.',
|
||||
noResultsMessage: 'Aucun résultat ne correspond à vos critères.',
|
||||
expandable: false,
|
||||
expandedKeys: () => new Set<string>(),
|
||||
canExpand: undefined,
|
||||
tableClass: '',
|
||||
showCounter: true,
|
||||
showPerPage: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'sort', sort: DataTableSort): void
|
||||
(e: 'update:currentPage', page: number): void
|
||||
(e: 'update:perPage', perPage: number): void
|
||||
(e: 'update:columnFilters', filters: DataTableColumnFilters): void
|
||||
(e: 'toggle-expand', key: string): void
|
||||
}>()
|
||||
|
||||
const hasFilterableColumns = computed(() =>
|
||||
props.columns.some(col => col.filterable),
|
||||
)
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
if (props.pagination) {
|
||||
return props.pagination.totalItems === 0
|
||||
}
|
||||
return props.rows.length === 0
|
||||
})
|
||||
|
||||
const getRowKey = (row: any): string => {
|
||||
return String(row[props.rowKey] ?? '')
|
||||
}
|
||||
|
||||
const isExpanded = (row: any): boolean => {
|
||||
return props.expandedKeys?.has(getRowKey(row)) ?? false
|
||||
}
|
||||
|
||||
const sortKeyForColumn = (col: DataTableColumn): string => {
|
||||
return col.sortKey ?? col.key
|
||||
}
|
||||
|
||||
const isSortedAsc = (col: DataTableColumn): boolean => {
|
||||
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'asc'
|
||||
}
|
||||
|
||||
const isSortedDesc = (col: DataTableColumn): boolean => {
|
||||
return props.sort?.field === sortKeyForColumn(col) && props.sort?.direction === 'desc'
|
||||
}
|
||||
|
||||
const handleHeaderSort = (col: DataTableColumn) => {
|
||||
const key = sortKeyForColumn(col)
|
||||
const currentDirection = props.sort?.field === key ? props.sort.direction : null
|
||||
|
||||
emit('sort', {
|
||||
field: key,
|
||||
direction: currentDirection === 'asc' ? 'desc' : 'asc',
|
||||
})
|
||||
}
|
||||
|
||||
let filterDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleFilterInput = (key: string, value: string) => {
|
||||
if (filterDebounceTimer) clearTimeout(filterDebounceTimer)
|
||||
filterDebounceTimer = setTimeout(() => {
|
||||
const updated = { ...props.columnFilters, [key]: value }
|
||||
// Remove empty filter keys
|
||||
for (const k of Object.keys(updated)) {
|
||||
if (!updated[k]) delete updated[k]
|
||||
}
|
||||
emit('update:columnFilters', updated)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const alignClass = (col: DataTableColumn): string => {
|
||||
if (col.align === 'center') return 'text-center'
|
||||
if (col.align === 'right') return 'text-right'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
118
frontend/app/components/common/DocumentListInline.vue
Normal file
118
frontend/app/components/common/DocumentListInline.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div v-if="documents.length" class="space-y-2">
|
||||
<div
|
||||
v-for="document in documents"
|
||||
:key="document.id || document.path || document.name"
|
||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
||||
:class="documentThumbnailClass(document)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && (document.fileUrl || document.path)"
|
||||
:src="document.fileUrl || document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(document)"
|
||||
:src="documentPreviewSrc(document)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(document).component"
|
||||
class="h-6 w-6"
|
||||
:class="documentIcon(document).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium flex items-center gap-2">
|
||||
{{ document.name }}
|
||||
<span class="badge badge-sm badge-outline">{{ getDocumentTypeLabel(document.type || 'documentation') }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-base-content/70">
|
||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
title="Modifier"
|
||||
@click="$emit('edit', document)"
|
||||
>
|
||||
Modifier
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(document)"
|
||||
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="$emit('preview', document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(document)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
<button
|
||||
v-if="canDelete"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="deleteDisabled"
|
||||
@click="$emit('delete', document.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-base-content/70">
|
||||
{{ emptyText }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getDocumentTypeLabel } from '~/shared/documentTypes'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
documentIcon,
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
import type { Document } from '~/composables/useDocuments'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
documents: Document[]
|
||||
canDelete?: boolean
|
||||
canEdit?: boolean
|
||||
deleteDisabled?: boolean
|
||||
emptyText?: string
|
||||
}>(), {
|
||||
canDelete: false,
|
||||
canEdit: false,
|
||||
deleteDisabled: false,
|
||||
emptyText: 'Aucun document.',
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'preview', document: Document): void
|
||||
(e: 'delete', documentId: string): void
|
||||
(e: 'edit', document: Document): void
|
||||
}>()
|
||||
</script>
|
||||
97
frontend/app/components/common/EntityHistorySection.vue
Normal file
97
frontend/app/components/common/EntityHistorySection.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="entries.length" class="badge badge-outline">
|
||||
{{ entries.length }} entrée{{ entries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l'historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-warning">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="entries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="diffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in diffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
historyActionLabel,
|
||||
formatHistoryDate,
|
||||
historyDiffEntries,
|
||||
type HistoryDiffEntry,
|
||||
} from '~/shared/utils/historyDisplayUtils'
|
||||
|
||||
interface HistoryEntry {
|
||||
id: string
|
||||
action: string
|
||||
createdAt: string
|
||||
actor?: { label?: string } | null
|
||||
diff?: Record<string, { from?: unknown; to?: unknown }> | null
|
||||
snapshot?: { name?: string } | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
entries: HistoryEntry[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
fieldLabels: Record<string, string>
|
||||
}>()
|
||||
|
||||
const diffEntries = (entry: HistoryEntry): HistoryDiffEntry[] =>
|
||||
historyDiffEntries(entry, props.fieldLabels)
|
||||
</script>
|
||||
170
frontend/app/components/common/EntityVersionList.vue
Normal file
170
frontend/app/components/common/EntityVersionList.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Versions</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Historique des versions avec possibilite de restauration.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="versions.length" class="badge badge-outline">
|
||||
{{ versions.length }} version{{ versions.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement des versions...
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="alert alert-warning">
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="versions.length === 0" class="text-xs text-base-content/70">
|
||||
Aucune version enregistree.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-96 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in versions"
|
||||
:key="entry.version"
|
||||
class="flex items-center justify-between rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-sm font-semibold">v{{ entry.version }}</span>
|
||||
<span
|
||||
v-if="entry.version === currentVersion"
|
||||
class="badge badge-primary badge-sm"
|
||||
>
|
||||
actuelle
|
||||
</span>
|
||||
<span
|
||||
v-if="entry.action === 'restore'"
|
||||
class="badge badge-warning badge-sm"
|
||||
>
|
||||
restauration
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
||||
<span>{{ actionLabel(entry.action) }}</span>
|
||||
<span>·</span>
|
||||
<span>{{ formatDate(entry.createdAt) }}</span>
|
||||
<span v-if="entry.actor">· {{ entry.actor.label }}</span>
|
||||
</div>
|
||||
<div v-if="entry.diff && Object.keys(entry.diff).length" class="mt-1 flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="(change, field) in entry.diff"
|
||||
:key="field"
|
||||
class="badge badge-ghost badge-xs"
|
||||
>
|
||||
{{ formatDiffEntry(String(field), change) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canRestore && entry.version !== currentVersion"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="restoring"
|
||||
@click="handleRestore(entry.version)"
|
||||
>
|
||||
Restaurer
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<VersionRestoreModal
|
||||
:visible="modalVisible"
|
||||
:preview="previewData"
|
||||
:restoring="restoring"
|
||||
:field-labels="fieldLabels"
|
||||
:entity-type="entityType"
|
||||
@close="modalVisible = false"
|
||||
@confirm="confirmRestore"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, toRef } from 'vue'
|
||||
import { useEntityVersions, type RestorePreview } from '~/composables/useEntityVersions'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { formatHistoryDate, historyActionLabel } from '~/shared/utils/historyDisplayUtils'
|
||||
import VersionRestoreModal from './VersionRestoreModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||
entityId: string
|
||||
fieldLabels: Record<string, string>
|
||||
/** Increment this value to force a refresh of the versions list */
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
restored: []
|
||||
}>()
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
const canRestore = computed(() => canEdit.value)
|
||||
|
||||
const { versions, loading, error, fetchVersions, fetchPreview, restore } = useEntityVersions({
|
||||
entityType: props.entityType,
|
||||
entityId: props.entityId,
|
||||
})
|
||||
|
||||
const currentVersion = computed(() => {
|
||||
if (versions.value.length === 0) return null
|
||||
return versions.value[0]?.version ?? null
|
||||
})
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const previewData = ref<RestorePreview | null>(null)
|
||||
const restoring = ref(false)
|
||||
const targetVersion = ref<number | null>(null)
|
||||
|
||||
const actionLabel = (action: string) => historyActionLabel(action)
|
||||
const formatDate = (date: string) => formatHistoryDate(date)
|
||||
|
||||
const formatDiffEntry = (field: string, change: { from: unknown; to: unknown }): string => {
|
||||
const label = props.fieldLabels[field] || field
|
||||
// Link changes (addedComponent, removedPiece, etc.) have {id, name} as value
|
||||
const val = change.to ?? change.from
|
||||
if (val && typeof val === 'object' && 'name' in (val as Record<string, unknown>)) {
|
||||
return `${label}: ${(val as Record<string, unknown>).name}`
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
const handleRestore = async (version: number) => {
|
||||
targetVersion.value = version
|
||||
previewData.value = null
|
||||
modalVisible.value = true
|
||||
previewData.value = await fetchPreview(version)
|
||||
}
|
||||
|
||||
const confirmRestore = async () => {
|
||||
if (!targetVersion.value) return
|
||||
restoring.value = true
|
||||
const result = await restore(targetVersion.value)
|
||||
restoring.value = false
|
||||
if (result?.success) {
|
||||
modalVisible.value = false
|
||||
await fetchVersions()
|
||||
emit('restored')
|
||||
}
|
||||
else {
|
||||
error.value = 'La restauration a echoue.'
|
||||
modalVisible.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchVersions()
|
||||
})
|
||||
|
||||
// Auto-refresh when parent signals a data change
|
||||
watch(toRef(props, 'refreshKey'), () => {
|
||||
fetchVersions()
|
||||
})
|
||||
</script>
|
||||
128
frontend/app/components/common/Pagination.vue
Normal file
128
frontend/app/components/common/Pagination.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<IconLucideChevronFirst class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<IconLucideChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<template v-for="page in visiblePages" :key="page">
|
||||
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<IconLucideChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<IconLucideChevronLast class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
|
||||
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
maxVisiblePages: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:currentPage'])
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const total = props.totalPages
|
||||
const current = props.currentPage
|
||||
const maxVisible = props.maxVisiblePages
|
||||
|
||||
if (total <= maxVisible + 2) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1)
|
||||
|
||||
const half = Math.floor(maxVisible / 2)
|
||||
let start = Math.max(2, current - half)
|
||||
let end = Math.min(total - 1, current + half)
|
||||
|
||||
// Adjust if near start
|
||||
if (current <= half + 1) {
|
||||
end = maxVisible
|
||||
}
|
||||
// Adjust if near end
|
||||
if (current >= total - half) {
|
||||
start = total - maxVisible + 1
|
||||
}
|
||||
|
||||
if (start > 2) {
|
||||
pages.push('ellipsis-start')
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (end < total - 1) {
|
||||
pages.push('ellipsis-end')
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
|
||||
emit('update:currentPage', page)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
406
frontend/app/components/common/RequirementListEditor.vue
Normal file
406
frontend/app/components/common/RequirementListEditor.vue
Normal file
@@ -0,0 +1,406 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="card-title text-lg">{{ labels.headerTitle }}</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
|
||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
||||
{{ labels.addButton }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-base-content/50">
|
||||
{{ labels.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="requirements.length === 0" class="text-sm text-base-content/50 bg-base-200/60 rounded-md p-4">
|
||||
{{ labels.emptyState }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(requirement, index) in requirements"
|
||||
:key="requirement.id || index"
|
||||
class="relative border border-base-200 rounded-lg p-4 pl-12 space-y-3 transition-colors"
|
||||
:class="requirementReorderClass(index)"
|
||||
@dragenter="onRequirementDragEnter(index)"
|
||||
@dragover="onRequirementDragOver"
|
||||
@drop="onRequirementDrop(index)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-3 top-4 btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing"
|
||||
draggable="true"
|
||||
title="Réorganiser"
|
||||
@dragstart="onRequirementDragStart(index, $event)"
|
||||
@dragend="onRequirementDragEnd"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ labels.typeSelectLabel }}</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
:model-value="normalizeTypeModel(requirement[typeField])"
|
||||
:options="typeOptions"
|
||||
:loading="typeLoading"
|
||||
size="sm"
|
||||
:placeholder="labels.typePlaceholder"
|
||||
:empty-text="typeOptions.length ? 'Aucun résultat' : 'Aucune option disponible'"
|
||||
:option-label="optionLabel"
|
||||
:option-description="optionDescription"
|
||||
@update:modelValue="(value) => updateRequirement(index, { [typeField]: normalizeTypeValue(value) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ labels.labelFieldLabel }}</span>
|
||||
<span v-if="labels.labelFieldHelper" class="label-text-alt text-xs">{{ labels.labelFieldHelper }}</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.label ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:placeholder="labels.labelPlaceholder"
|
||||
@input="handleLabelInput(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ labels.minLabel }}</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.minCount ?? minFallback"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="handleMinInput(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">{{ labels.maxLabel }}</span>
|
||||
<span v-if="labels.maxHelper" class="label-text-alt text-xs">{{ labels.maxHelper }}</span>
|
||||
</label>
|
||||
<input
|
||||
:value="requirement.maxCount ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input input-bordered input-sm"
|
||||
@input="handleMaxInput(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-square btn-error btn-sm"
|
||||
@click="removeRequirement(index)"
|
||||
>
|
||||
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4 text-sm">
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="(requirement.required ?? requiredFallback) === true"
|
||||
@change="handleRequiredChange(index, $event)"
|
||||
/>
|
||||
{{ labels.requiredLabel }}
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true"
|
||||
@change="handleAllowNewModelsChange(index, $event)"
|
||||
/>
|
||||
{{ labels.allowNewModelsLabel }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
|
||||
type Option = {
|
||||
id: string | number
|
||||
name: string
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
type Requirement = Record<string, unknown> & {
|
||||
id?: string | number
|
||||
label?: string
|
||||
minCount?: number | null
|
||||
maxCount?: number | null
|
||||
required?: boolean | null
|
||||
allowNewModels?: boolean | null
|
||||
orderIndex?: number | null
|
||||
}
|
||||
|
||||
type Labels = {
|
||||
headerTitle: string
|
||||
addButton: string
|
||||
description: string
|
||||
emptyState: string
|
||||
typeSelectLabel: string
|
||||
typePlaceholder: string
|
||||
labelFieldLabel: string
|
||||
labelFieldHelper?: string
|
||||
labelPlaceholder?: string
|
||||
minLabel: string
|
||||
maxLabel: string
|
||||
maxHelper?: string
|
||||
requiredLabel: string
|
||||
allowNewModelsLabel: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array as PropType<Requirement[]>,
|
||||
default: () => [],
|
||||
},
|
||||
typeOptions: {
|
||||
type: Array as PropType<Option[]>,
|
||||
default: () => [],
|
||||
},
|
||||
typeField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
labels: {
|
||||
type: Object as PropType<Labels>,
|
||||
required: true,
|
||||
},
|
||||
defaultRequirement: {
|
||||
type: Function as PropType<() => Requirement>,
|
||||
required: true,
|
||||
},
|
||||
requiredFallback: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowNewModelsFallback: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
minFallback: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
typeLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const requirements = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const optionLabel = (option: Option) => {
|
||||
if (!option) {
|
||||
return ''
|
||||
}
|
||||
return option.name || ''
|
||||
}
|
||||
|
||||
const optionDescription = (option: Option) => {
|
||||
if (!option) {
|
||||
return ''
|
||||
}
|
||||
if (typeof option.description === 'string' && option.description.trim()) {
|
||||
return option.description.trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const applyOrderIndex = (list: Requirement[]): Requirement[] =>
|
||||
list.map((item, index) => ({
|
||||
...item,
|
||||
orderIndex: index,
|
||||
}))
|
||||
|
||||
const addRequirement = () => {
|
||||
requirements.value = applyOrderIndex([
|
||||
...requirements.value,
|
||||
props.defaultRequirement(),
|
||||
])
|
||||
}
|
||||
|
||||
const removeRequirement = (index: number) => {
|
||||
requirements.value = applyOrderIndex(
|
||||
requirements.value.filter((_, i) => i !== index),
|
||||
)
|
||||
}
|
||||
|
||||
const updateRequirement = (index: number, patch: Partial<Requirement>) => {
|
||||
requirements.value = applyOrderIndex(
|
||||
requirements.value.map((item, i) =>
|
||||
i === index ? { ...item, ...patch } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const parseNumber = (value: string) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
const parseOptionalNumber = (value: string) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
// Type-safe event handlers
|
||||
const getInputValue = (event: Event): string => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
return target?.value ?? ''
|
||||
}
|
||||
|
||||
const getCheckboxValue = (event: Event): boolean => {
|
||||
const target = event.target as HTMLInputElement | null
|
||||
return target?.checked ?? false
|
||||
}
|
||||
|
||||
const handleLabelInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { label: getInputValue(event) })
|
||||
}
|
||||
|
||||
const handleMinInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { minCount: parseNumber(getInputValue(event)) })
|
||||
}
|
||||
|
||||
const handleMaxInput = (index: number, event: Event) => {
|
||||
updateRequirement(index, { maxCount: parseOptionalNumber(getInputValue(event)) })
|
||||
}
|
||||
|
||||
const handleRequiredChange = (index: number, event: Event) => {
|
||||
updateRequirement(index, { required: getCheckboxValue(event) })
|
||||
}
|
||||
|
||||
const handleAllowNewModelsChange = (index: number, event: Event) => {
|
||||
updateRequirement(index, { allowNewModels: getCheckboxValue(event) })
|
||||
}
|
||||
|
||||
const draggingRequirementIndex = ref<number | null>(null)
|
||||
const requirementDropTargetIndex = ref<number | null>(null)
|
||||
|
||||
const resetRequirementDragState = () => {
|
||||
draggingRequirementIndex.value = null
|
||||
requirementDropTargetIndex.value = null
|
||||
}
|
||||
|
||||
const reorderRequirements = (from: number, to: number) => {
|
||||
const list = requirements.value
|
||||
if (!Array.isArray(list)) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
if (from === to || from < 0 || to < 0 || from >= list.length || to >= list.length) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
const updated = list.slice() as Requirement[]
|
||||
const [moved] = updated.splice(from, 1)
|
||||
if (!moved) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
updated.splice(to, 0, moved)
|
||||
requirements.value = applyOrderIndex(updated)
|
||||
resetRequirementDragState()
|
||||
}
|
||||
|
||||
const onRequirementDragStart = (index: number, event: DragEvent) => {
|
||||
draggingRequirementIndex.value = index
|
||||
requirementDropTargetIndex.value = index
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
}
|
||||
|
||||
const onRequirementDragEnter = (index: number) => {
|
||||
if (draggingRequirementIndex.value === null) {
|
||||
return
|
||||
}
|
||||
requirementDropTargetIndex.value = index
|
||||
}
|
||||
|
||||
const onRequirementDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const onRequirementDrop = (index: number) => {
|
||||
if (draggingRequirementIndex.value === null) {
|
||||
resetRequirementDragState()
|
||||
return
|
||||
}
|
||||
reorderRequirements(draggingRequirementIndex.value, index)
|
||||
}
|
||||
|
||||
const onRequirementDragEnd = () => {
|
||||
resetRequirementDragState()
|
||||
}
|
||||
|
||||
const requirementReorderClass = (index: number) => {
|
||||
if (draggingRequirementIndex.value === index) {
|
||||
return 'border-dashed border-primary'
|
||||
}
|
||||
if (
|
||||
draggingRequirementIndex.value !== null &&
|
||||
requirementDropTargetIndex.value === index &&
|
||||
draggingRequirementIndex.value !== index
|
||||
) {
|
||||
return 'border-primary border-dashed bg-primary/5'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizeTypeModel = (value: unknown) => {
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const normalizeTypeValue = (value: string | number | null | undefined) => {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Éditeur générique de groupes de contraintes (pièces/composants) pour les types de machine.
|
||||
Paramétrer les libellés et la structure via les props pour réutiliser ce bloc.
|
||||
-->
|
||||
362
frontend/app/components/common/SearchSelect.vue
Normal file
362
frontend/app/components/common/SearchSelect.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<template>
|
||||
<div class="space-y-1 search-select">
|
||||
<label v-if="$slots.label" class="label">
|
||||
<span class="label-text">
|
||||
<slot name="label" />
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="inputRef"
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
:placeholder="placeholder"
|
||||
:class="inputClasses"
|
||||
@focus="handleFocus"
|
||||
@keydown.down.prevent="highlightNext"
|
||||
@keydown.up.prevent="highlightPrevious"
|
||||
@keydown.enter.prevent="selectHighlighted"
|
||||
@input="handleInput"
|
||||
>
|
||||
<button
|
||||
v-if="clearable && modelValue"
|
||||
type="button"
|
||||
class="absolute top-1/2 -translate-y-1/2 right-8 btn btn-ghost btn-xs"
|
||||
aria-label="Effacer la sélection"
|
||||
@click.stop="clearSelection"
|
||||
>
|
||||
<IconLucideX class="w-3 h-3" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="toggleButtonClasses"
|
||||
@click="toggleDropdown"
|
||||
aria-label="Afficher les options"
|
||||
>
|
||||
<IconLucideChevronsUpDown class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="openDropdown"
|
||||
class="absolute z-30 mt-1 w-full max-h-60 overflow-y-auto bg-base-100 border border-base-200 rounded-box shadow-lg"
|
||||
>
|
||||
<div v-if="loading" class="flex items-center gap-2 px-3 py-2 text-xs text-base-content/50">
|
||||
<span class="loading loading-spinner loading-xs" />
|
||||
Recherche en cours…
|
||||
</div>
|
||||
<div v-else-if="displayedOptions.length === 0" class="px-3 py-2 text-xs text-base-content/50">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
<ul v-else class="flex flex-col">
|
||||
<li
|
||||
v-for="(option, index) in displayedOptions"
|
||||
:key="resolveValue(option) ?? index"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col items-start gap-1 px-3 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
:class="{
|
||||
'bg-base-200': isOptionSelected(option),
|
||||
'bg-base-300/60': highlightedIndex === index
|
||||
}"
|
||||
@mouseenter="highlightedIndex = index"
|
||||
@mouseleave="highlightedIndex = -1"
|
||||
@click="selectOption(option)"
|
||||
>
|
||||
<span class="font-medium text-sm">
|
||||
<slot name="option-label" :option="option">
|
||||
{{ resolveLabel(option) }}
|
||||
</slot>
|
||||
</span>
|
||||
<span v-if="$slots['option-description'] || resolveDescription(option)" class="text-xs text-base-content/50">
|
||||
<slot name="option-description" :option="option">
|
||||
{{ resolveDescription(option) }}
|
||||
</slot>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
import IconLucideX from '~icons/lucide/x'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Rechercher…'
|
||||
},
|
||||
emptyText: {
|
||||
type: String,
|
||||
default: 'Aucun résultat'
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
optionValue: {
|
||||
type: [String, Function],
|
||||
default: 'id'
|
||||
},
|
||||
optionLabel: {
|
||||
type: [String, Function],
|
||||
default: 'name'
|
||||
},
|
||||
optionDescription: {
|
||||
type: [String, Function],
|
||||
default: null
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator: (value) => ['xs', 'sm', 'md', 'lg'].includes(value)
|
||||
},
|
||||
maxVisible: {
|
||||
type: Number,
|
||||
default: 50
|
||||
},
|
||||
serverSearch: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const openDropdown = ref(false)
|
||||
const highlightedIndex = ref(-1)
|
||||
const inputRef = ref(null)
|
||||
|
||||
const baseOptions = computed(() => Array.isArray(props.options) ? props.options : [])
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return baseOptions.value.find(option => isEqualValue(resolveValue(option), props.modelValue)) || null
|
||||
})
|
||||
|
||||
const displayedOptions = computed(() => {
|
||||
const items = baseOptions.value.slice()
|
||||
|
||||
const filtered = (!props.serverSearch && searchTerm.value.trim())
|
||||
? items.filter((option) => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const label = resolveLabel(option).toLowerCase()
|
||||
const description = resolveDescription(option)?.toLowerCase() || ''
|
||||
return label.includes(term) || description.includes(term)
|
||||
})
|
||||
: items
|
||||
|
||||
if (props.maxVisible && filtered.length > props.maxVisible) {
|
||||
return filtered.slice(0, props.maxVisible)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
const pr = props.clearable && props.modelValue ? 'pr-16' : 'pr-10'
|
||||
const base = ['input', 'input-bordered', 'w-full', pr]
|
||||
if (props.size === 'xs') base.push('input-xs')
|
||||
if (props.size === 'sm') base.push('input-sm')
|
||||
if (props.size === 'lg') base.push('input-lg')
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
const toggleButtonClasses = computed(() => {
|
||||
const base = ['absolute', 'top-1/2', '-translate-y-1/2', 'right-2', 'btn', 'btn-ghost']
|
||||
if (props.size === 'xs' || props.size === 'sm') {
|
||||
base.push('btn-xs')
|
||||
} else {
|
||||
base.push('btn-sm')
|
||||
}
|
||||
return base.join(' ')
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
baseOptions,
|
||||
(_newOptions) => {
|
||||
if (!openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(openDropdown, (isOpen) => {
|
||||
if (isOpen) {
|
||||
highlightedIndex.value = -1
|
||||
}
|
||||
})
|
||||
|
||||
function resolveValue (option) {
|
||||
if (!option) {
|
||||
return null
|
||||
}
|
||||
if (typeof props.optionValue === 'function') {
|
||||
return props.optionValue(option)
|
||||
}
|
||||
return option[props.optionValue]
|
||||
}
|
||||
|
||||
function resolveLabel (option) {
|
||||
if (!option) {
|
||||
return ''
|
||||
}
|
||||
if (typeof props.optionLabel === 'function') {
|
||||
return props.optionLabel(option) || ''
|
||||
}
|
||||
return option[props.optionLabel] || ''
|
||||
}
|
||||
|
||||
function resolveDescription (option) {
|
||||
if (!option || !props.optionDescription) {
|
||||
return ''
|
||||
}
|
||||
if (typeof props.optionDescription === 'function') {
|
||||
return props.optionDescription(option) || ''
|
||||
}
|
||||
return option[props.optionDescription] || ''
|
||||
}
|
||||
|
||||
function isEqualValue (a, b) {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
return String(a ?? '') === String(b ?? '')
|
||||
}
|
||||
|
||||
function isOptionSelected (option) {
|
||||
return isEqualValue(resolveValue(option), props.modelValue)
|
||||
}
|
||||
|
||||
function selectOption (option) {
|
||||
emit('update:modelValue', resolveValue(option) ?? '')
|
||||
searchTerm.value = resolveLabel(option)
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function handleFocus () {
|
||||
openDropdown.value = true
|
||||
if (searchTerm.value === '' && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDropdown () {
|
||||
openDropdown.value = !openDropdown.value
|
||||
if (openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
if (openDropdown.value && inputRef.value) {
|
||||
inputRef.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput () {
|
||||
if (!openDropdown.value) {
|
||||
openDropdown.value = true
|
||||
}
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
|
||||
function clearSelection () {
|
||||
emit('update:modelValue', '')
|
||||
searchTerm.value = ''
|
||||
openDropdown.value = false
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
openDropdown.value = false
|
||||
if (searchTerm.value.trim() === '' && selectedOption.value) {
|
||||
emit('update:modelValue', '')
|
||||
} else if (selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
|
||||
function highlightNext () {
|
||||
if (!openDropdown.value || displayedOptions.value.length === 0) {
|
||||
return
|
||||
}
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % displayedOptions.value.length
|
||||
}
|
||||
|
||||
function highlightPrevious () {
|
||||
if (!openDropdown.value || displayedOptions.value.length === 0) {
|
||||
return
|
||||
}
|
||||
highlightedIndex.value =
|
||||
highlightedIndex.value <= 0
|
||||
? displayedOptions.value.length - 1
|
||||
: highlightedIndex.value - 1
|
||||
}
|
||||
|
||||
function selectHighlighted () {
|
||||
if (!openDropdown.value) {
|
||||
return
|
||||
}
|
||||
if (highlightedIndex.value >= 0 && highlightedIndex.value < displayedOptions.value.length) {
|
||||
selectOption(displayedOptions.value[highlightedIndex.value])
|
||||
}
|
||||
}
|
||||
|
||||
const handleGlobalClick = (event) => {
|
||||
if (!openDropdown.value) {
|
||||
return
|
||||
}
|
||||
const target = event.target
|
||||
if (target?.closest?.('.search-select')) {
|
||||
return
|
||||
}
|
||||
closeDropdown()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('click', handleGlobalClick)
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : ''
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('click', handleGlobalClick)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
162
frontend/app/components/common/StructureSkeletonPreview.vue
Normal file
162
frontend/app/components/common/StructureSkeletonPreview.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<div class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ previewBadge }}</span>
|
||||
</div>
|
||||
|
||||
<details v-if="structure" class="collapse collapse-arrow bg-base-100">
|
||||
<summary class="collapse-title text-sm font-medium">
|
||||
Consulter le détail du squelette
|
||||
</summary>
|
||||
<div class="collapse-content text-sm text-base-content/80" :class="variant === 'component' ? 'space-y-4' : 'space-y-2'">
|
||||
<!-- Custom fields: component variant (rich display) -->
|
||||
<div v-if="variant === 'component' && customFields.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="field in customFields"
|
||||
:key="field.customFieldId || field.id || field.name"
|
||||
class="rounded bg-base-200/60 px-3 py-2"
|
||||
>
|
||||
<p class="font-medium text-sm text-base-content">
|
||||
{{ field.name || field.key }}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/70 mt-1">
|
||||
Type : {{ field.type || 'text' }}<span v-if="field.required"> • Obligatoire</span>
|
||||
<span v-if="Array.isArray(field.options) && field.options.length">
|
||||
• Options : {{ field.options.join(', ') }}
|
||||
</span>
|
||||
<span v-if="field.defaultValue">
|
||||
• Défaut : {{ field.defaultValue }}
|
||||
</span>
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Custom fields: piece variant (simple display) -->
|
||||
<div v-if="variant === 'piece' && customFields.length" class="space-y-1">
|
||||
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li v-for="field in customFields" :key="field.name">
|
||||
<span class="font-medium">{{ field.name }}</span>
|
||||
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Pieces: component variant only -->
|
||||
<div v-if="variant === 'component' && pieces.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Pièces imposées</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(piece, index) in pieces"
|
||||
:key="piece.role || piece.typePieceId || piece.familyCode || index"
|
||||
>
|
||||
{{ resolvePieceLabelFn(piece) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Products: component variant only -->
|
||||
<div v-if="variant === 'component' && products.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(product, index) in products"
|
||||
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||
>
|
||||
{{ resolveProductLabelFn(product) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Subcomponents: component variant only -->
|
||||
<div v-if="variant === 'component' && subcomponents.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(subcomponent, index) in subcomponents"
|
||||
:key="subcomponent.alias || subcomponent.typeComposantId || subcomponent.familyCode || index"
|
||||
>
|
||||
{{ resolveSubcomponentLabelFn(subcomponent) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Empty state: component variant -->
|
||||
<p
|
||||
v-if="variant === 'component' && showEmptyState && !customFields.length && !pieces.length && !products.length && !subcomponents.length"
|
||||
class="text-xs text-base-content/50"
|
||||
>
|
||||
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
||||
</p>
|
||||
|
||||
<!-- Empty state: piece variant -->
|
||||
<p v-if="variant === 'piece' && !customFields.length" class="text-xs text-base-content/70">
|
||||
Ce squelette ne définit pas encore de champs personnalisés.
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
getStructureCustomFields,
|
||||
getStructurePieces,
|
||||
getStructureProducts,
|
||||
getStructureSubcomponents,
|
||||
} from '~/shared/utils/structureDisplayUtils'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
structure: Record<string, any> | null
|
||||
description?: string
|
||||
previewBadge: string
|
||||
variant: 'component' | 'piece'
|
||||
showEmptyState?: boolean
|
||||
resolvePieceLabel?: (piece: Record<string, any>) => string
|
||||
resolveProductLabel?: (product: Record<string, any>) => string
|
||||
resolveSubcomponentLabel?: (subcomponent: Record<string, any>) => string
|
||||
}>(), {
|
||||
description: '',
|
||||
showEmptyState: false,
|
||||
resolvePieceLabel: undefined,
|
||||
resolveProductLabel: undefined,
|
||||
resolveSubcomponentLabel: undefined,
|
||||
})
|
||||
|
||||
const customFields = computed(() =>
|
||||
getStructureCustomFields(props.structure),
|
||||
)
|
||||
|
||||
const pieces = computed(() =>
|
||||
props.variant === 'component' ? getStructurePieces(props.structure) : [],
|
||||
)
|
||||
|
||||
const products = computed(() =>
|
||||
props.variant === 'component' ? getStructureProducts(props.structure) : [],
|
||||
)
|
||||
|
||||
const subcomponents = computed(() =>
|
||||
props.variant === 'component' ? getStructureSubcomponents(props.structure) : [],
|
||||
)
|
||||
|
||||
const fallbackLabel = (item: Record<string, any>) =>
|
||||
item?.name || item?.label || item?.role || item?.alias || 'N/A'
|
||||
|
||||
const resolvePieceLabelFn = (piece: Record<string, any>) =>
|
||||
props.resolvePieceLabel ? props.resolvePieceLabel(piece) : fallbackLabel(piece)
|
||||
|
||||
const resolveProductLabelFn = (product: Record<string, any>) =>
|
||||
props.resolveProductLabel ? props.resolveProductLabel(product) : fallbackLabel(product)
|
||||
|
||||
const resolveSubcomponentLabelFn = (subcomponent: Record<string, any>) =>
|
||||
props.resolveSubcomponentLabel ? props.resolveSubcomponentLabel(subcomponent) : fallbackLabel(subcomponent)
|
||||
</script>
|
||||
198
frontend/app/components/common/VersionRestoreModal.vue
Normal file
198
frontend/app/components/common/VersionRestoreModal.vue
Normal file
@@ -0,0 +1,198 @@
|
||||
<template>
|
||||
<dialog ref="dialogRef" class="modal" :class="{ 'modal-open': visible }">
|
||||
<div class="modal-box max-w-lg">
|
||||
<h3 class="text-lg font-bold">Restaurer la version {{ preview?.version }}</h3>
|
||||
|
||||
<div v-if="!preview" class="flex justify-center py-8">
|
||||
<span class="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="mt-4 space-y-4">
|
||||
<!-- Restore mode explanation -->
|
||||
<div
|
||||
class="alert text-sm"
|
||||
:class="preview.restoreMode === 'full' ? 'alert-info' : 'alert-warning'"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<!-- FULL MODE -->
|
||||
<template v-if="preview.restoreMode === 'full'">
|
||||
<span class="font-semibold">Restauration complete</span>
|
||||
|
||||
<!-- Machine: always full, no category -->
|
||||
<template v-if="entityType === 'machine'">
|
||||
<span>Tous les elements de la machine seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix</li>
|
||||
<li>Site</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Composants, pieces et produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Composant -->
|
||||
<template v-else-if="entityType === 'composant'">
|
||||
<span>La categorie est identique. Tous les elements du composant seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Structure : pieces, sous-composants et produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Piece -->
|
||||
<template v-else-if="entityType === 'piece'">
|
||||
<span>La categorie est identique. Tous les elements de la piece seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Produits lies</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Product -->
|
||||
<template v-else-if="entityType === 'product'">
|
||||
<span>La categorie est identique. Tous les elements du produit seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix fournisseur</li>
|
||||
<li>Fournisseurs</li>
|
||||
<li>Champs personnalises</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- PARTIAL MODE (never for machines) -->
|
||||
<template v-else>
|
||||
<span class="font-semibold">Restauration partielle</span>
|
||||
|
||||
<!-- Composant -->
|
||||
<template v-if="entityType === 'composant'">
|
||||
<span>La categorie du composant a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Structure actuelle (pieces, sous-composants, produits lies)</li>
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Piece -->
|
||||
<template v-else-if="entityType === 'piece'">
|
||||
<span>La categorie de la piece a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, description, prix</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Produits lies actuels</li>
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Product -->
|
||||
<template v-else-if="entityType === 'product'">
|
||||
<span>La categorie du produit a change depuis cette version. Seuls les champs de base seront restaures :</span>
|
||||
<ul class="ml-4 list-disc text-xs">
|
||||
<li>Nom, reference, prix fournisseur</li>
|
||||
<li>Fournisseurs</li>
|
||||
</ul>
|
||||
<span class="mt-1 text-xs font-medium">Ne seront PAS modifies :</span>
|
||||
<ul class="ml-4 list-disc text-xs opacity-70">
|
||||
<li>Champs personnalises actuels</li>
|
||||
</ul>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diff -->
|
||||
<div v-if="Object.keys(preview.diff).length" class="space-y-2">
|
||||
<h4 class="text-sm font-semibold">Changements qui seront appliques</h4>
|
||||
<ul class="space-y-1 text-sm">
|
||||
<li
|
||||
v-for="(change, field) in preview.diff"
|
||||
:key="field"
|
||||
class="flex flex-col rounded-md border border-base-200 px-3 py-2"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ fieldLabels[field] || formatFieldLabel(String(field)) }}</span>
|
||||
<span class="text-xs text-error line-through">{{ formatValue(change.current) }}</span>
|
||||
<span class="text-xs text-success">{{ formatValue(change.restored) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-sm text-base-content/60">
|
||||
Aucune difference detectee — l'entite est deja dans l'etat de cette version.
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div v-if="preview.warnings.length" class="space-y-1">
|
||||
<h4 class="text-sm font-semibold text-warning">Avertissements</h4>
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="(warning, i) in preview.warnings"
|
||||
:key="i"
|
||||
class="alert alert-warning py-2 text-xs"
|
||||
>
|
||||
{{ warning.message }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button class="btn btn-ghost btn-sm md:btn-md" :disabled="restoring" @click="$emit('close')">
|
||||
Annuler
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm md:btn-md" :disabled="restoring" @click="$emit('confirm')">
|
||||
<span v-if="restoring" class="loading loading-spinner loading-sm mr-2" />
|
||||
Confirmer la restauration
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" @click="$emit('close')">
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RestorePreview } from '~/composables/useEntityVersions'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
preview: RestorePreview | null
|
||||
restoring: boolean
|
||||
fieldLabels: Record<string, string>
|
||||
entityType: 'machine' | 'composant' | 'piece' | 'product'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
confirm: []
|
||||
}>()
|
||||
|
||||
const formatFieldLabel = (field: string): string => {
|
||||
if (field.startsWith('customField:')) {
|
||||
return `Champ perso : ${field.replace('customField:', '')}`
|
||||
}
|
||||
return field
|
||||
}
|
||||
|
||||
const formatValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => (typeof v === 'object' && v !== null ? (v as any).name || (v as any).id || JSON.stringify(v) : String(v))).join(', ') || '—'
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
</script>
|
||||
112
frontend/app/components/form/FieldEmail.vue
Normal file
112
frontend/app/components/form/FieldEmail.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="form-control">
|
||||
<label v-if="label" class="label" :for="inputId">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<span v-if="required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
type="email"
|
||||
class="input input-bordered"
|
||||
:class="{ 'input-error': Boolean(errorMessage) }"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:pattern="EMAIL_INPUT_PATTERN"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="(event) => emit('focus', event)"
|
||||
/>
|
||||
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500">
|
||||
{{ help }}
|
||||
</p>
|
||||
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { normalizeEmail } from '~/utils/formatters/email'
|
||||
import { EMAIL_INPUT_PATTERN, EMAIL_VALIDATION_ERROR, emailSchema } from '~/shared/validation/email'
|
||||
|
||||
type Emits = {
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'blur', value: FocusEvent): void
|
||||
(event: 'focus', value: FocusEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
help?: string | null
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
autocomplete?: string
|
||||
normalizeOnBlur?: boolean
|
||||
validateOnBlur?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: 'Email',
|
||||
required: false,
|
||||
error: null,
|
||||
help: null,
|
||||
placeholder: 'ex: contact@example.com',
|
||||
disabled: false,
|
||||
name: undefined,
|
||||
id: undefined,
|
||||
autocomplete: 'email',
|
||||
normalizeOnBlur: false,
|
||||
validateOnBlur: false,
|
||||
}
|
||||
)
|
||||
|
||||
const fallbackId = useId()
|
||||
const inputId = computed(() => props.id || fallbackId)
|
||||
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
|
||||
const errorMessage = computed(() => props.error || null)
|
||||
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
|
||||
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
if (props.normalizeOnBlur) {
|
||||
const normalized = normalizeEmail(target.value)
|
||||
if (normalized !== target.value) {
|
||||
target.value = normalized
|
||||
emit('update:modelValue', normalized)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.validateOnBlur && !errorMessage.value) {
|
||||
const validation = emailSchema.validate(target.value)
|
||||
if (!validation.valid) {
|
||||
target.setCustomValidity(EMAIL_VALIDATION_ERROR)
|
||||
} else {
|
||||
target.setCustomValidity('')
|
||||
}
|
||||
}
|
||||
|
||||
emit('blur', event)
|
||||
}
|
||||
</script>
|
||||
113
frontend/app/components/form/FieldPhone.vue
Normal file
113
frontend/app/components/form/FieldPhone.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="form-control">
|
||||
<label v-if="label" class="label" :for="inputId">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<span v-if="required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
type="tel"
|
||||
class="input input-bordered"
|
||||
:class="{ 'input-error': Boolean(errorMessage) }"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:pattern="PHONE_INPUT_PATTERN"
|
||||
inputmode="tel"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="(event) => emit('focus', event)"
|
||||
/>
|
||||
<p v-if="help" :id="helpId" class="mt-2 text-xs text-base-content/50">
|
||||
{{ help }}
|
||||
</p>
|
||||
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import { PHONE_INPUT_PATTERN, PHONE_VALIDATION_ERROR, phoneSchema } from '~/shared/validation/phone'
|
||||
|
||||
type Emits = {
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'blur', value: FocusEvent): void
|
||||
(event: 'focus', value: FocusEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
help?: string | null
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
autocomplete?: string
|
||||
normalizeOnBlur?: boolean
|
||||
validateOnBlur?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: 'Téléphone',
|
||||
required: false,
|
||||
error: null,
|
||||
help: null,
|
||||
placeholder: 'Ex: 06 00 00 00 00',
|
||||
disabled: false,
|
||||
name: undefined,
|
||||
id: undefined,
|
||||
autocomplete: 'tel',
|
||||
normalizeOnBlur: false,
|
||||
validateOnBlur: false,
|
||||
}
|
||||
)
|
||||
|
||||
const fallbackId = useId()
|
||||
const inputId = computed(() => props.id || fallbackId)
|
||||
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
|
||||
const errorMessage = computed(() => props.error || null)
|
||||
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
|
||||
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
if (props.normalizeOnBlur) {
|
||||
const formatted = formatPhone(target.value)
|
||||
if (formatted !== target.value) {
|
||||
target.value = formatted
|
||||
emit('update:modelValue', formatted)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.validateOnBlur && !errorMessage.value) {
|
||||
const validation = phoneSchema.validate(target.value)
|
||||
if (!validation.valid) {
|
||||
target.setCustomValidity(PHONE_VALIDATION_ERROR)
|
||||
} else {
|
||||
target.setCustomValidity('')
|
||||
}
|
||||
}
|
||||
|
||||
emit('blur', event)
|
||||
}
|
||||
</script>
|
||||
108
frontend/app/components/home/AddMachineModal.vue
Normal file
108
frontend/app/components/home/AddMachineModal.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Ajouter une nouvelle machine
|
||||
</h3>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom de la machine</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="Ex: Presse hydraulique #1"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Site</span>
|
||||
</label>
|
||||
<select
|
||||
v-model="form.siteId"
|
||||
class="select select-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
>
|
||||
<option value="">
|
||||
Sélectionner un site
|
||||
</option>
|
||||
<option v-for="site in sites" :key="site.id" :value="site.id">
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.reference"
|
||||
type="text"
|
||||
placeholder="Ex: PRESS-001"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||
Créer la machine
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
sites: Array<{ id: string, name: string }>
|
||||
disabled: boolean
|
||||
preselectedSiteId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
create: [data: { name: string, siteId: string, reference: string }]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
siteId: '',
|
||||
reference: '',
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
emit('create', { ...form })
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (isOpen && props.preselectedSiteId) {
|
||||
form.siteId = props.preselectedSiteId
|
||||
}
|
||||
if (!isOpen) {
|
||||
form.name = ''
|
||||
form.siteId = ''
|
||||
form.reference = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
78
frontend/app/components/home/AddSiteModal.vue
Normal file
78
frontend/app/components/home/AddSiteModal.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg mb-4">
|
||||
Ajouter un nouveau site
|
||||
</h3>
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du site</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="Ex: Usine de production"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<SiteContactFormFields :form="form" :disabled="disabled" />
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="disabled">
|
||||
Créer le site
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
disabled: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
create: [data: { name: string, contactName: string, contactPhone: string, contactAddress: string, contactPostalCode: string, contactCity: string }]
|
||||
}>()
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
contactAddress: '',
|
||||
contactPostalCode: '',
|
||||
contactCity: '',
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
emit('create', { ...form })
|
||||
}
|
||||
|
||||
watch(() => props.open, (isOpen) => {
|
||||
if (!isOpen) {
|
||||
form.name = ''
|
||||
form.contactName = ''
|
||||
form.contactPhone = ''
|
||||
form.contactAddress = ''
|
||||
form.contactPostalCode = ''
|
||||
form.contactCity = ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
466
frontend/app/components/layout/AppNavbar.vue
Normal file
466
frontend/app/components/layout/AppNavbar.vue
Normal file
@@ -0,0 +1,466 @@
|
||||
<template>
|
||||
<div class="navbar navbar-glass sticky top-0 z-50 px-4 lg:px-6">
|
||||
<div class="navbar-start">
|
||||
<!-- Mobile hamburger menu -->
|
||||
<div class="dropdown">
|
||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm lg:hidden">
|
||||
<IconLucideMenu class="w-5 h-5" aria-hidden="true" />
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu menu-sm dropdown-content mt-3 z-[1] p-3 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
|
||||
>
|
||||
<li class="pt-1 pb-2 lg:hidden">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
|
||||
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
|
||||
{{ isDark ? 'Mode clair' : 'Mode sombre' }}
|
||||
</button>
|
||||
</li>
|
||||
<li class="pt-1 pb-2 lg:hidden">
|
||||
<button
|
||||
class="w-full flex items-center gap-2 rounded-lg px-3 py-2 transition-colors text-base-content/70 hover:bg-primary/8 hover:text-primary"
|
||||
@click="$emit('open-settings')"
|
||||
>
|
||||
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||
Paramètres d'affichage
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Mobile: simple links -->
|
||||
<li v-for="link in simpleLinks" :key="link.to">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
class="rounded-lg px-3 py-2 transition-all flex items-center gap-2"
|
||||
:class="linkClass(link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
<!-- Mobile: dropdown groups -->
|
||||
<li
|
||||
v-for="group in navGroups"
|
||||
:key="group.id + '-mobile'"
|
||||
class="mt-1 border-t border-base-200 pt-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left transition-all"
|
||||
:class="groupClass(group)"
|
||||
:aria-expanded="openDropdown === group.id + '-mobile'"
|
||||
@click="toggleDropdown(group.id + '-mobile')"
|
||||
@keydown.enter.prevent="toggleDropdown(group.id + '-mobile')"
|
||||
@keydown.space.prevent="toggleDropdown(group.id + '-mobile')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ group.label }}
|
||||
</span>
|
||||
<IconLucideChevronRight
|
||||
class="h-3.5 w-3.5 transition-transform duration-200"
|
||||
:class="openDropdown === group.id + '-mobile' ? 'rotate-90' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-mobile">
|
||||
<ul
|
||||
v-if="openDropdown === group.id + '-mobile'"
|
||||
class="mt-1 space-y-0.5 rounded-lg bg-base-200/50 p-2 overflow-hidden"
|
||||
>
|
||||
<li v-for="child in group.children" :key="child.to">
|
||||
<NuxtLink
|
||||
:to="child.to"
|
||||
class="rounded-md px-3 py-1.5 transition-colors block text-sm"
|
||||
:class="childLinkClass(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Logo -->
|
||||
<NuxtLink to="/" class="flex items-center gap-2.5 group">
|
||||
<div class="w-9 h-9 rounded-lg overflow-hidden ring-1 ring-base-300/50 transition-all group-hover:ring-primary/30 group-hover:shadow-md">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo Malio"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-lg font-bold tracking-tight text-base-content hidden sm:inline" style="font-family: var(--font-heading)">
|
||||
Inventory
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Desktop navbar -->
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal gap-0.5 px-1">
|
||||
<!-- Desktop: simple links -->
|
||||
<li v-for="link in simpleLinks" :key="link.to">
|
||||
<NuxtLink
|
||||
:to="link.to"
|
||||
class="transition-all px-3 py-2 rounded-lg flex items-center gap-1.5 text-sm font-medium"
|
||||
:class="linkClass(link)"
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ link.label }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
|
||||
<!-- Desktop: dropdown groups -->
|
||||
<li
|
||||
v-for="group in navGroups"
|
||||
:key="group.id + '-desktop'"
|
||||
class="relative"
|
||||
@mouseenter="setDropdown(group.id + '-desktop')"
|
||||
@mouseleave="scheduleDropdownClose(group.id + '-desktop')"
|
||||
@focusin="setDropdown(group.id + '-desktop')"
|
||||
@focusout="scheduleDropdownClose(group.id + '-desktop')"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg px-3 py-2 transition-all text-sm font-medium"
|
||||
:class="groupClass(group)"
|
||||
:aria-expanded="openDropdown === group.id + '-desktop'"
|
||||
@click="toggleDropdown(group.id + '-desktop')"
|
||||
@keydown.enter.prevent="toggleDropdown(group.id + '-desktop')"
|
||||
@keydown.space.prevent="toggleDropdown(group.id + '-desktop')"
|
||||
>
|
||||
<component :is="group.icon" v-if="group.icon" class="w-4 h-4" aria-hidden="true" />
|
||||
{{ group.label }}
|
||||
<IconLucideChevronDown
|
||||
class="h-3.5 w-3.5 transition-transform duration-200"
|
||||
:class="openDropdown === group.id + '-desktop' ? 'rotate-180' : ''"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="nav-dropdown-desktop">
|
||||
<ul
|
||||
v-if="openDropdown === group.id + '-desktop'"
|
||||
class="absolute left-0 top-full mt-1.5 w-56 rounded-xl border border-base-300/50 bg-base-100 p-1.5 shadow-lg shadow-base-content/5 z-50"
|
||||
>
|
||||
<li v-for="child in group.children" :key="child.to">
|
||||
<NuxtLink
|
||||
:to="child.to"
|
||||
class="block rounded-lg px-3 py-2 transition-all text-sm"
|
||||
:class="childLinkClass(child)"
|
||||
>
|
||||
{{ child.label }}
|
||||
<span v-if="child.to === '/comments' && unresolvedCount > 0" class="badge badge-warning badge-xs ml-1">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Navbar end -->
|
||||
<div class="navbar-end">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
|
||||
:title="isDark ? 'Mode clair' : 'Mode sombre'"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<IconLucideSun v-if="isDark" class="w-4 h-4" aria-hidden="true" />
|
||||
<IconLucideMoon v-else class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle hidden lg:inline-flex text-base-content/50 hover:text-base-content"
|
||||
title="Paramètres d'affichage"
|
||||
@click="$emit('open-settings')"
|
||||
>
|
||||
<IconLucideSettings class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<ClientOnly>
|
||||
<div v-if="activeProfile" class="dropdown dropdown-end">
|
||||
<div
|
||||
tabindex="0"
|
||||
role="button"
|
||||
class="indicator cursor-pointer"
|
||||
>
|
||||
<span
|
||||
v-if="unresolvedCount > 0"
|
||||
class="indicator-item badge badge-warning badge-xs"
|
||||
>
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
<div
|
||||
class="bg-primary text-primary-content rounded-full w-8 h-8 flex items-center justify-center"
|
||||
>
|
||||
<span class="text-xs font-semibold">
|
||||
{{ activeProfileInitials }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="menu dropdown-content mt-3 p-2 shadow-lg bg-base-100 rounded-xl w-60 border border-base-300/50"
|
||||
>
|
||||
<li class="px-3 py-2">
|
||||
<div class="flex flex-col gap-1 pointer-events-none">
|
||||
<span class="text-xs text-base-content/50">Connecté en tant que</span>
|
||||
<span class="font-semibold text-sm text-base-content">{{ activeProfileLabel }}</span>
|
||||
<span class="badge badge-sm" :class="roleBadgeClass">{{ roleLabel }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<div class="divider my-0.5 px-2" />
|
||||
<li v-if="isAdmin">
|
||||
<NuxtLink to="/admin" class="rounded-lg justify-between text-sm">
|
||||
Administration
|
||||
<IconLucideChevronRight class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<li>
|
||||
<NuxtLink to="/comments" class="rounded-lg justify-between text-sm">
|
||||
Commentaires
|
||||
<span v-if="unresolvedCount > 0" class="badge badge-warning badge-xs">
|
||||
{{ unresolvedCount }}
|
||||
</span>
|
||||
<IconLucideChevronRight v-else class="w-3.5 h-3.5 text-base-content/30" aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<div class="divider my-0.5 px-2" />
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg text-error/80 hover:text-error hover:bg-error/5 justify-between text-sm"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
Déconnexion
|
||||
<IconLucideLogOut class="w-3.5 h-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, type Component } from 'vue'
|
||||
import { useRoute } from '#imports'
|
||||
import { useNavDropdown } from '~/composables/useNavDropdown'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { useProfileSession } from '~/composables/useProfileSession'
|
||||
import { useComments } from '~/composables/useComments'
|
||||
import IconLucideMenu from '~icons/lucide/menu'
|
||||
import IconLucideSettings from '~icons/lucide/settings'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideLogOut from '~icons/lucide/log-out'
|
||||
import IconLucideLayoutDashboard from '~icons/lucide/layout-dashboard'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
|
||||
import IconLucideCpu from '~icons/lucide/cpu'
|
||||
import IconLucidePuzzle from '~icons/lucide/puzzle'
|
||||
import IconLucidePackage from '~icons/lucide/package'
|
||||
import IconLucideLink from '~icons/lucide/link'
|
||||
import IconLucideSun from '~icons/lucide/sun'
|
||||
import IconLucideMoon from '~icons/lucide/moon'
|
||||
import logoSrc from '~/assets/LOGO_CARRE_BLANC.png'
|
||||
|
||||
defineEmits<{
|
||||
(e: 'open-settings'): void
|
||||
(e: 'logout'): void
|
||||
}>()
|
||||
|
||||
interface NavLink {
|
||||
to: string
|
||||
label: string
|
||||
icon?: Component
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component
|
||||
activePaths: string[]
|
||||
children: NavLink[]
|
||||
}
|
||||
|
||||
const simpleLinks: NavLink[] = [
|
||||
{ to: '/', label: 'Vue d\'ensemble', icon: IconLucideLayoutDashboard },
|
||||
{ to: '/machines', label: 'Parc Machines', icon: IconLucideFactory },
|
||||
]
|
||||
|
||||
const navGroups: NavGroup[] = [
|
||||
{
|
||||
id: 'component',
|
||||
label: 'Composants',
|
||||
icon: IconLucideCpu,
|
||||
activePaths: ['/component-category', '/component-catalog'],
|
||||
children: [
|
||||
{ to: '/component-catalog', label: 'Catalogue des composants' },
|
||||
{ to: '/component-category', label: 'Catégorie de composant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'pieces',
|
||||
label: 'Pièces',
|
||||
icon: IconLucidePuzzle,
|
||||
activePaths: ['/piece-category', '/pieces-catalog'],
|
||||
children: [
|
||||
{ to: '/pieces-catalog', label: 'Catalogue des pièces' },
|
||||
{ to: '/piece-category', label: 'Catégorie de pièce' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'products',
|
||||
label: 'Produits',
|
||||
icon: IconLucidePackage,
|
||||
activePaths: ['/product-category', '/product-catalog'],
|
||||
children: [
|
||||
{ to: '/product-catalog', label: 'Catalogue des produits' },
|
||||
{ to: '/product-category', label: 'Catégorie de produit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'resources',
|
||||
label: 'Ressources liées',
|
||||
icon: IconLucideLink,
|
||||
activePaths: ['/sites', '/documents', '/constructeurs', '/activity-log', '/comments'],
|
||||
children: [
|
||||
{ to: '/sites', label: 'Sites' },
|
||||
{ to: '/documents', label: 'Documents' },
|
||||
{ to: '/constructeurs', label: 'Fournisseurs' },
|
||||
{ to: '/comments', label: 'Commentaires' },
|
||||
{ to: '/activity-log', label: 'Journal d\'activité' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const { openDropdown, setDropdown, scheduleDropdownClose, toggleDropdown } = useNavDropdown()
|
||||
const { activeProfile } = useProfileSession()
|
||||
const { isAdmin, canEdit } = usePermissions()
|
||||
const { fetchUnresolvedCount } = useComments()
|
||||
const { isDark, toggle: toggleDarkMode, init: initDarkMode } = useDarkMode()
|
||||
|
||||
const unresolvedCount = ref(0)
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const refreshUnresolvedCount = async () => {
|
||||
if (!activeProfile.value) return
|
||||
unresolvedCount.value = await fetchUnresolvedCount()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initDarkMode()
|
||||
refreshUnresolvedCount()
|
||||
pollInterval = setInterval(refreshUnresolvedCount, 60_000)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
})
|
||||
|
||||
const isActive = (path: string) => {
|
||||
if (path === '/') {
|
||||
return route.path === '/'
|
||||
}
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
const isGroupActive = (group: NavGroup) => {
|
||||
return group.activePaths.some((path) => isActive(path))
|
||||
}
|
||||
|
||||
const linkClass = (link: NavLink) => {
|
||||
return isActive(link.to)
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
||||
}
|
||||
|
||||
const groupClass = (group: NavGroup) => {
|
||||
return isGroupActive(group)
|
||||
? 'bg-primary text-primary-content font-semibold shadow-sm'
|
||||
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
||||
}
|
||||
|
||||
const childLinkClass = (child: NavLink) => {
|
||||
return isActive(child.to)
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: 'text-base-content/70 hover:bg-base-content/5 hover:text-base-content'
|
||||
}
|
||||
|
||||
const roleLabel = computed(() => {
|
||||
if (isAdmin.value) return 'Admin'
|
||||
if (canEdit.value) return 'Gestionnaire'
|
||||
return 'Lecteur'
|
||||
})
|
||||
|
||||
const roleBadgeClass = computed(() => {
|
||||
if (isAdmin.value) return 'badge-error'
|
||||
if (canEdit.value) return 'badge-warning'
|
||||
return 'badge-info'
|
||||
})
|
||||
|
||||
const activeProfileLabel = computed(() => {
|
||||
if (!activeProfile.value) {
|
||||
return 'Profil inconnu'
|
||||
}
|
||||
return `${activeProfile.value.firstName} ${activeProfile.value.lastName}`
|
||||
})
|
||||
|
||||
const activeProfileInitials = computed(() => {
|
||||
if (!activeProfile.value) {
|
||||
return '??'
|
||||
}
|
||||
const { firstName = '', lastName = '' } = activeProfile.value
|
||||
return (
|
||||
`${firstName.charAt(0) || ''}${lastName.charAt(0) || ''}`.toUpperCase() || '??'
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-dropdown-desktop-enter-active,
|
||||
.nav-dropdown-desktop-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.nav-dropdown-desktop-enter-from,
|
||||
.nav-dropdown-desktop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px) scale(0.98);
|
||||
}
|
||||
.nav-dropdown-desktop-enter-to,
|
||||
.nav-dropdown-desktop-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
.nav-dropdown-mobile-enter-active,
|
||||
.nav-dropdown-mobile-leave-active {
|
||||
transition: max-height 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.nav-dropdown-mobile-enter-from,
|
||||
.nav-dropdown-mobile-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
.nav-dropdown-mobile-enter-to,
|
||||
.nav-dropdown-mobile-leave-from {
|
||||
max-height: 12rem;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
236
frontend/app/components/machine/AddEntityToMachineModal.vue
Normal file
236
frontend/app/components/machine/AddEntityToMachineModal.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div v-if="open" class="modal modal-open">
|
||||
<div class="modal-box max-w-xl w-full" style="overflow: visible">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3"
|
||||
@click="handleClose"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h3 class="font-bold text-lg mb-6">
|
||||
{{ title }}
|
||||
</h3>
|
||||
|
||||
<!-- Step 1: Choose category -->
|
||||
<div class="form-control mb-5" style="position: relative; z-index: 20">
|
||||
<label class="label pb-1">
|
||||
<span class="label-text font-medium">Catégorie</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
v-model="selectedTypeId"
|
||||
:options="types"
|
||||
:loading="loadingTypes"
|
||||
:max-visible="8"
|
||||
placeholder="Rechercher une catégorie..."
|
||||
empty-text="Aucune catégorie disponible"
|
||||
:option-label="(t: any) => t.name"
|
||||
:option-description="(t: any) => t.code"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Choose entity (visible only after category selected) -->
|
||||
<div v-if="selectedTypeName" class="form-control mb-5" style="position: relative; z-index: 10">
|
||||
<label class="label pb-1">
|
||||
<span class="label-text font-medium">{{ entityLabel }}</span>
|
||||
</label>
|
||||
<SearchSelect
|
||||
v-model="selectedEntityId"
|
||||
:options="entities"
|
||||
:loading="loadingEntities"
|
||||
:max-visible="8"
|
||||
:placeholder="`Rechercher ${entityLabelLower}...`"
|
||||
:empty-text="`Aucun ${entityLabelLower} disponible dans cette catégorie`"
|
||||
:option-label="entityOptionLabel"
|
||||
:option-description="entityOptionDescription"
|
||||
server-search
|
||||
@search="handleEntitySearch"
|
||||
/>
|
||||
</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>
|
||||
<p v-if="selectedEntitySummary.reference" class="text-xs text-base-content/60">
|
||||
Réf : {{ selectedEntitySummary.reference }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="modal-action mt-4 pt-4 border-t border-base-200" style="position: relative; z-index: 0">
|
||||
<button type="button" class="btn btn-ghost" @click="handleClose">
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="!selectedEntityId"
|
||||
@click="handleConfirm"
|
||||
>
|
||||
Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" @click="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
|
||||
type EntityKind = 'component' | 'piece' | 'product'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
entityKind: EntityKind
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
confirm: [entityId: string]
|
||||
}>()
|
||||
|
||||
const selectedTypeId = ref('')
|
||||
const selectedEntityId = ref('')
|
||||
const loadingEntities = ref(false)
|
||||
const entities = ref<any[]>([])
|
||||
|
||||
const { componentTypes, loadingComponentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadingPieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadingProductTypes, loadProductTypes } = useProductTypes()
|
||||
const { loadComposants } = useComposants()
|
||||
const { loadPieces } = usePieces()
|
||||
const { loadProducts } = useProducts()
|
||||
|
||||
const title = computed(() => {
|
||||
const labels: Record<EntityKind, string> = {
|
||||
component: 'Ajouter un composant',
|
||||
piece: 'Ajouter une pièce',
|
||||
product: 'Ajouter un produit',
|
||||
}
|
||||
return labels[props.entityKind]
|
||||
})
|
||||
|
||||
const entityLabel = computed(() => {
|
||||
const labels: Record<EntityKind, string> = {
|
||||
component: 'Composant',
|
||||
piece: 'Pièce',
|
||||
product: 'Produit',
|
||||
}
|
||||
return labels[props.entityKind]
|
||||
})
|
||||
|
||||
const entityLabelLower = computed(() => entityLabel.value.toLowerCase())
|
||||
|
||||
const types = computed(() => {
|
||||
if (props.entityKind === 'component') return componentTypes.value
|
||||
if (props.entityKind === 'piece') return pieceTypes.value
|
||||
return productTypes.value
|
||||
})
|
||||
|
||||
const loadingTypes = computed(() => {
|
||||
if (props.entityKind === 'component') return loadingComponentTypes.value
|
||||
if (props.entityKind === 'piece') return loadingPieceTypes.value
|
||||
return loadingProductTypes.value
|
||||
})
|
||||
|
||||
const selectedTypeName = computed(() => {
|
||||
if (!selectedTypeId.value) return ''
|
||||
const found = types.value.find((t: any) => t.id === selectedTypeId.value)
|
||||
return found?.name || ''
|
||||
})
|
||||
|
||||
const entityOptionLabel = (e: any) => {
|
||||
const name = e.name || '(sans nom)'
|
||||
return e.reference ? `${name} — ${e.reference}` : name
|
||||
}
|
||||
const entityOptionDescription = (e: any) => e.reference || ''
|
||||
|
||||
const selectedEntitySummary = computed(() => {
|
||||
if (!selectedEntityId.value || !entities.value.length) return null
|
||||
const found = entities.value.find((e: any) => e.id === selectedEntityId.value)
|
||||
if (!found) return null
|
||||
return { name: found.name || '(sans nom)', reference: found.reference || null }
|
||||
})
|
||||
|
||||
// Load types when modal opens
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
if (props.entityKind === 'component') await loadComponentTypes()
|
||||
else if (props.entityKind === 'piece') await loadPieceTypes()
|
||||
else await loadProductTypes()
|
||||
})
|
||||
|
||||
// Load entities when type changes
|
||||
watch(selectedTypeId, async () => {
|
||||
selectedEntityId.value = ''
|
||||
entities.value = []
|
||||
|
||||
if (!selectedTypeName.value) return
|
||||
|
||||
loadingEntities.value = true
|
||||
try {
|
||||
if (props.entityKind === 'component') {
|
||||
const result = await loadComposants({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else if (props.entityKind === 'piece') {
|
||||
const result = await loadPieces({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else {
|
||||
const result = await loadProducts({ typeName: selectedTypeName.value, itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
}
|
||||
} finally {
|
||||
loadingEntities.value = false
|
||||
}
|
||||
})
|
||||
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const handleEntitySearch = (term: string) => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(async () => {
|
||||
if (!selectedTypeName.value) return
|
||||
loadingEntities.value = true
|
||||
try {
|
||||
if (props.entityKind === 'component') {
|
||||
const result = await loadComposants({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else if (props.entityKind === 'piece') {
|
||||
const result = await loadPieces({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
} else {
|
||||
const result = await loadProducts({ typeName: selectedTypeName.value, search: term.trim(), itemsPerPage: 200 })
|
||||
entities.value = result?.data?.items || []
|
||||
}
|
||||
} finally {
|
||||
loadingEntities.value = false
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!selectedEntityId.value) return
|
||||
emit('confirm', selectedEntityId.value)
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
selectedTypeId.value = ''
|
||||
selectedEntityId.value = ''
|
||||
entities.value = []
|
||||
}
|
||||
</script>
|
||||
72
frontend/app/components/machine/MachineComponentsCard.vue
Normal file
72
frontend/app/components/machine/MachineComponentsCard.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Composants</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Déplier tous les composants' : 'Replier tous les composants'"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="components.length === 0" class="text-sm text-gray-500 py-4">
|
||||
Aucun composant associé à cette machine.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="component in components" :key="component.id">
|
||||
<ComponentHierarchy
|
||||
:components="[component]"
|
||||
:is-edit-mode="false"
|
||||
:show-delete="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@edit-piece="$emit('edit-piece', $event)"
|
||||
@delete="$emit('remove-component', component.linkId || component.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-component')"
|
||||
>
|
||||
Ajouter un composant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ComponentHierarchy from '~/components/ComponentHierarchy.vue'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
|
||||
defineProps<{
|
||||
components: any[]
|
||||
isEditMode: boolean
|
||||
collapsed: boolean
|
||||
collapseToggleToken: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-collapse': []
|
||||
'update-component': [component: any]
|
||||
'edit-piece': [piece: any]
|
||||
'custom-field-update': [fieldUpdate: any]
|
||||
'add-component': []
|
||||
'remove-component': [linkId: string]
|
||||
}>()
|
||||
</script>
|
||||
112
frontend/app/components/machine/MachineCustomFieldDefEditor.vue
Normal file
112
frontend/app/components/machine/MachineCustomFieldDefEditor.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<section class="space-y-3">
|
||||
<h3 class="text-sm font-semibold">
|
||||
Définitions des champs personnalisés
|
||||
</h3>
|
||||
|
||||
<p v-if="!fields.length" class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini. Cliquez sur « Ajouter » pour en créer un.
|
||||
</p>
|
||||
|
||||
<ul v-else class="space-y-2" role="list">
|
||||
<li
|
||||
v-for="(field, index) in fields"
|
||||
:key="field.uid"
|
||||
class="border border-base-200 rounded-md p-3 space-y-2 bg-base-100 transition-colors"
|
||||
:class="reorderClass(index)"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart(index, $event)"
|
||||
@dragenter="onDragEnter(index)"
|
||||
@dragover.prevent="onDragEnter(index)"
|
||||
@drop.prevent="onDrop(index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square cursor-grab active:cursor-grabbing mt-1"
|
||||
title="Réordonner"
|
||||
draggable="false"
|
||||
>
|
||||
<IconLucideGripVertical class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<input
|
||||
v-model="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Nom du champ"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-sm">
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
<option value="number">
|
||||
Nombre
|
||||
</option>
|
||||
<option value="select">
|
||||
Liste
|
||||
</option>
|
||||
<option value="boolean">
|
||||
Oui/Non
|
||||
</option>
|
||||
<option value="date">
|
||||
Date
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-if="field.type === 'select'"
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-sm h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="$emit('remove-field', index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button type="button" class="btn btn-outline btn-sm" @click="$emit('add-field')">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter un champ
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MachineCustomFieldEditorField } from '~/composables/useMachineCustomFieldDefs'
|
||||
import IconLucideGripVertical from '~icons/lucide/grip-vertical'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
|
||||
defineProps<{
|
||||
fields: MachineCustomFieldEditorField[]
|
||||
saving: boolean
|
||||
reorderClass: (index: number) => string
|
||||
onDragStart: (index: number, event: DragEvent) => void
|
||||
onDragEnter: (index: number) => void
|
||||
onDrop: (index: number) => void
|
||||
onDragEnd: () => void
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-field': []
|
||||
'remove-field': [index: number]
|
||||
}>()
|
||||
</script>
|
||||
221
frontend/app/components/machine/MachineCustomFieldsCard.vue
Normal file
221
frontend/app/components/machine/MachineCustomFieldsCard.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Champs personnalisés</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
Champs personnalisés propres à cette machine.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="visibleCustomFields.length" class="badge badge-outline">
|
||||
{{ visibleCustomFields.length }} champ{{ visibleCustomFields.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- View mode: display values -->
|
||||
<template v-if="!isEditMode">
|
||||
<div v-if="visibleCustomFields.length" class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in visibleCustomFields"
|
||||
:key="field.customFieldValueId || field.id || field.name"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
</label>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini pour cette machine.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<!-- Edit mode: definition management + value editing -->
|
||||
<template v-else>
|
||||
<p v-if="!customFields.length" class="text-xs text-gray-500">
|
||||
Aucun champ personnalisé défini.
|
||||
</p>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="(field, index) in customFields"
|
||||
:key="field.id || field.name || index"
|
||||
class="border border-base-200 rounded-md p-3 space-y-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 space-y-2">
|
||||
<!-- Definition fields -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<input
|
||||
:value="field.name"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
placeholder="Nom du champ"
|
||||
@blur="handleDefinitionUpdate(field, 'name', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
:value="field.type || 'text'"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleDefinitionUpdate(field, 'type', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="text">Texte</option>
|
||||
<option value="number">Nombre</option>
|
||||
<option value="select">Liste</option>
|
||||
<option value="boolean">Oui/Non</option>
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
:checked="!!field.required"
|
||||
@change="handleDefinitionUpdate(field, 'required', ($event.target as HTMLInputElement).checked)"
|
||||
/>
|
||||
Obligatoire
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Options for select type -->
|
||||
<textarea
|
||||
v-if="(field.type || 'text') === 'select'"
|
||||
:value="field.optionsText || (Array.isArray(field.options) ? field.options.join('\n') : '')"
|
||||
class="textarea textarea-bordered textarea-sm h-20 w-full"
|
||||
placeholder="Option 1 Option 2"
|
||||
@blur="handleOptionsUpdate(field, ($event.target as HTMLTextAreaElement).value)"
|
||||
></textarea>
|
||||
|
||||
<!-- Value editing -->
|
||||
<div class="pt-1 border-t border-base-200">
|
||||
<label class="label py-0">
|
||||
<span class="label-text text-xs text-base-content/60">Valeur</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="!field.type || field.type === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Valeur..."
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Valeur..."
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm w-full"
|
||||
@change="onSelectChange(field, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label
|
||||
v-else-if="field.type === 'boolean'"
|
||||
class="flex items-center gap-3 cursor-pointer py-1"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="onBooleanChange(field, ($event.target as HTMLInputElement).checked)"
|
||||
>
|
||||
<span
|
||||
class="text-sm"
|
||||
:class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'"
|
||||
>
|
||||
{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm w-full"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('update-custom-field', field)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-square flex-shrink-0 text-error"
|
||||
title="Supprimer ce champ"
|
||||
@click="$emit('delete-field', field.id || field.customFieldId)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-field')"
|
||||
>
|
||||
Ajouter un champ personnalisé
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideTrash from '~icons/lucide/trash'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
|
||||
defineProps<{
|
||||
customFields: any[]
|
||||
visibleCustomFields: any[]
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'set-custom-field-value': [field: any, value: unknown]
|
||||
'update-custom-field': [field: any]
|
||||
'add-field': []
|
||||
'delete-field': [fieldId: string]
|
||||
'update-field-definition': [fieldId: string, data: Record<string, unknown>]
|
||||
}>()
|
||||
|
||||
const handleDefinitionUpdate = (field: any, key: string, value: unknown) => {
|
||||
const fieldId = field.id || field.customFieldId
|
||||
if (!fieldId) return
|
||||
emit('update-field-definition', fieldId, { ...field, [key]: value })
|
||||
}
|
||||
|
||||
const handleOptionsUpdate = (field: any, raw: string) => {
|
||||
const fieldId = field.id || field.customFieldId
|
||||
if (!fieldId) return
|
||||
const options = raw.split('\n').map((o: string) => o.trim()).filter((o: string) => o.length > 0)
|
||||
emit('update-field-definition', fieldId, { ...field, options })
|
||||
}
|
||||
|
||||
const onSelectChange = (field: any, value: string) => {
|
||||
emit('set-custom-field-value', field, value)
|
||||
emit('update-custom-field', field)
|
||||
}
|
||||
|
||||
const onBooleanChange = (field: any, checked: boolean) => {
|
||||
emit('set-custom-field-value', field, checked ? 'true' : 'false')
|
||||
emit('update-custom-field', field)
|
||||
}
|
||||
</script>
|
||||
67
frontend/app/components/machine/MachineDetailHeader.vue
Normal file
67
frontend/app/components/machine/MachineDetailHeader.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h1 class="text-3xl font-bold">
|
||||
{{ title }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 print:hidden" data-print-hide>
|
||||
<button
|
||||
@click="$emit('toggle-edit')"
|
||||
class="btn btn-primary"
|
||||
:class="{ 'btn-outline': isEditMode }"
|
||||
>
|
||||
<IconLucideSquarePen
|
||||
v-if="!isEditMode"
|
||||
class="w-5 h-5 mr-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<IconLucideEye
|
||||
v-else
|
||||
class="w-5 h-5 mr-2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ isEditMode ? 'Voir détails' : 'Modifier' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!isEditMode"
|
||||
@click="$emit('open-print')"
|
||||
type="button"
|
||||
class="btn btn-outline btn-secondary"
|
||||
>
|
||||
<IconLucidePrinter class="w-5 h-5 mr-2" aria-hidden="true" />
|
||||
Imprimer
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm md:btn-md" @click="goBack">
|
||||
Retour aux machines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideSquarePen from '~icons/lucide/square-pen'
|
||||
import IconLucideEye from '~icons/lucide/eye'
|
||||
import IconLucidePrinter from '~icons/lucide/printer'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-edit': []
|
||||
'open-print': []
|
||||
}>()
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
}
|
||||
else {
|
||||
navigateTo('/machines')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
116
frontend/app/components/machine/MachineDocumentsCard.vue
Normal file
116
frontend/app/components/machine/MachineDocumentsCard.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm mt-6">
|
||||
<div class="card-body space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Documents de la machine</h2>
|
||||
<p class="text-xs text-gray-500">Ajoutez ou consultez les documents liés à cette machine.</p>
|
||||
</div>
|
||||
<span v-if="isEditMode && files.length" class="badge badge-outline">
|
||||
{{ files.length }} fichier{{ files.length > 1 ? 's' : '' }} sélectionné{{ files.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DocumentUpload
|
||||
v-if="isEditMode"
|
||||
:model-value="files"
|
||||
@update:model-value="$emit('update:files', $event)"
|
||||
title="Déposer des fichiers pour la machine"
|
||||
subtitle="Formats acceptés : PDF, images, documents..."
|
||||
@files-added="$emit('files-added', $event)"
|
||||
/>
|
||||
|
||||
<div v-if="documents.length" class="space-y-2">
|
||||
<div
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
|
||||
:class="documentThumbnailClass(doc)"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(doc) && (doc.fileUrl || doc.path)"
|
||||
:src="doc.fileUrl || doc.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${doc.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(doc)"
|
||||
:src="documentPreviewSrc(doc)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-6 w-6"
|
||||
:class="documentIcon(doc).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium">{{ doc.name }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ doc.mimeType || 'Inconnu' }} • {{ formatSize(doc.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="$emit('preview', doc)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="$emit('download', doc)">
|
||||
Télécharger
|
||||
</button>
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
:disabled="uploading"
|
||||
@click="$emit('remove', doc.id)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">Aucun document lié à cette machine.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import { canPreviewDocument, isImageDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
formatSize,
|
||||
shouldInlinePdf,
|
||||
documentPreviewSrc,
|
||||
documentThumbnailClass,
|
||||
documentIcon,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps<{
|
||||
documents: any[]
|
||||
isEditMode: boolean
|
||||
uploading: boolean
|
||||
files: File[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:files': [files: File[]]
|
||||
'files-added': [files: File[]]
|
||||
'preview': [doc: any]
|
||||
'download': [doc: any]
|
||||
'remove': [documentId: string]
|
||||
}>()
|
||||
</script>
|
||||
230
frontend/app/components/machine/MachineInfoCard.vue
Normal file
230
frontend/app/components/machine/MachineInfoCard.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title tracking-tight">Informations de la machine</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="getMachineFieldId('name')"
|
||||
:value="machineName"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-name', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Site</span>
|
||||
</label>
|
||||
<select
|
||||
v-if="isEditMode"
|
||||
:value="machineSiteId"
|
||||
class="select select-bordered"
|
||||
@change="$emit('update:machine-site-id', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Sélectionner un site</option>
|
||||
<option
|
||||
v-for="site in sites"
|
||||
:key="site.id"
|
||||
:value="site.id"
|
||||
>
|
||||
{{ site.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineSiteName || 'Non défini' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isEditMode || machineReference" class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Référence</span>
|
||||
</label>
|
||||
<input
|
||||
v-if="isEditMode"
|
||||
:id="getMachineFieldId('reference')"
|
||||
:value="machineReference"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
@input="$emit('update:machine-reference', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else class="input input-bordered bg-base-200">
|
||||
{{ machineReference }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isEditMode || hasMachineConstructeur" class="form-control md:col-span-2">
|
||||
<label class="label">
|
||||
<span class="label-text">Fournisseur</span>
|
||||
</label>
|
||||
<ConstructeurSelect
|
||||
v-if="isEditMode"
|
||||
class="w-full"
|
||||
:model-value="machineConstructeurIds"
|
||||
:initial-options="machineConstructeursDisplay"
|
||||
placeholder="Rechercher un ou plusieurs fournisseurs..."
|
||||
@update:modelValue="$emit('update:constructeur-ids', $event)"
|
||||
/>
|
||||
<ConstructeurLinksTable
|
||||
v-if="constructeurLinks.length"
|
||||
:model-value="constructeurLinks"
|
||||
:readonly="!isEditMode"
|
||||
@update:model-value="$emit('update:constructeur-links', $event)"
|
||||
@remove="$emit('remove-constructeur-link', $event)"
|
||||
/>
|
||||
<div v-else-if="!isEditMode" class="border border-base-300 rounded-btn bg-base-200 px-4 py-2 min-h-12 flex items-center">
|
||||
<span class="text-base-content/50">Non défini</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs personnalisés -->
|
||||
<div v-if="visibleCustomFields.length" class="mt-6 pt-4 border-t border-base-200">
|
||||
<h4 class="font-semibold text-base-content/80 mb-3">Champs personnalisés de la machine</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in visibleCustomFields"
|
||||
:key="field.customFieldValueId || field.id || field.name"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">{{ field.name }}</span>
|
||||
<span v-if="field.required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
|
||||
<template v-if="isEditMode">
|
||||
<input
|
||||
v-if="field.type === 'text'"
|
||||
:value="field.value ?? ''"
|
||||
type="text"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<input
|
||||
v-else-if="field.type === 'number'"
|
||||
:value="field.value ?? ''"
|
||||
type="number"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<select
|
||||
v-else-if="field.type === 'select'"
|
||||
:value="field.value ?? ''"
|
||||
class="select select-bordered select-sm"
|
||||
:required="field.required"
|
||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option value="">Sélectionner...</option>
|
||||
<option
|
||||
v-for="option in field.options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
>
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
<label v-else-if="field.type === 'boolean'" class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary toggle-sm"
|
||||
:checked="String(field.value).toLowerCase() === 'true'"
|
||||
@change="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).checked ? 'true' : 'false')"
|
||||
>
|
||||
<span class="text-sm" :class="String(field.value).toLowerCase() === 'true' ? 'text-success font-medium' : 'text-base-content/60'">{{ String(field.value).toLowerCase() === 'true' ? 'Oui' : 'Non' }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else-if="field.type === 'date'"
|
||||
:value="field.value ?? ''"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
:required="field.required"
|
||||
@input="$emit('set-custom-field-value', field, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<div v-else class="text-xs text-error">
|
||||
Type de champ non pris en charge
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="input input-bordered input-sm bg-base-200">
|
||||
{{ formatCustomFieldValue(field) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isEditMode" class="mt-6 pt-4 border-t border-base-200">
|
||||
<MachineCustomFieldDefEditor
|
||||
:fields="fieldDefs.fields.value"
|
||||
:saving="fieldDefs.saving.value"
|
||||
:reorder-class="fieldDefs.reorderClass"
|
||||
:on-drag-start="fieldDefs.onDragStart"
|
||||
:on-drag-enter="fieldDefs.onDragEnter"
|
||||
:on-drop="fieldDefs.onDrop"
|
||||
:on-drag-end="fieldDefs.onDragEnd"
|
||||
@add-field="fieldDefs.addField()"
|
||||
@remove-field="fieldDefs.removeField($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import ConstructeurSelect from '~/components/ConstructeurSelect.vue'
|
||||
import ConstructeurLinksTable from '~/components/ConstructeurLinksTable.vue'
|
||||
import MachineCustomFieldDefEditor from '~/components/machine/MachineCustomFieldDefEditor.vue'
|
||||
import { formatCustomFieldValue } from '~/shared/utils/customFieldUtils'
|
||||
import { useMachineCustomFieldDefs } from '~/composables/useMachineCustomFieldDefs'
|
||||
import type { ConstructeurLinkEntry } from '~/shared/constructeurUtils'
|
||||
|
||||
const props = defineProps<{
|
||||
isEditMode: boolean
|
||||
machineName: string
|
||||
machineReference: string
|
||||
machineSiteId: string
|
||||
machineSiteName: string
|
||||
sites: any[]
|
||||
machineConstructeurIds: string[]
|
||||
machineConstructeursDisplay: any[]
|
||||
hasMachineConstructeur: boolean
|
||||
constructeurLinks: ConstructeurLinkEntry[]
|
||||
visibleCustomFields: any[]
|
||||
getMachineFieldId: (fieldName: string) => string
|
||||
machineId: string
|
||||
machineCustomFieldDefs: any[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:machine-name': [value: string]
|
||||
'update:machine-reference': [value: string]
|
||||
'update:machine-site-id': [value: string]
|
||||
'update:constructeur-ids': [ids: unknown]
|
||||
'update:constructeur-links': [links: ConstructeurLinkEntry[]]
|
||||
'remove-constructeur-link': [constructeurId: string]
|
||||
'set-custom-field-value': [field: any, value: unknown]
|
||||
'custom-fields-saved': []
|
||||
}>()
|
||||
|
||||
const fieldDefs = useMachineCustomFieldDefs({
|
||||
machineId: props.machineId,
|
||||
initialDefs: props.machineCustomFieldDefs,
|
||||
onSaved: () => emit('custom-fields-saved'),
|
||||
})
|
||||
|
||||
watch(() => props.machineCustomFieldDefs, (newDefs) => {
|
||||
fieldDefs.reinit(newDefs)
|
||||
}, { deep: true })
|
||||
|
||||
defineExpose({
|
||||
saveFieldDefinitions: () => fieldDefs.saveDefinitions(),
|
||||
})
|
||||
</script>
|
||||
71
frontend/app/components/machine/MachinePiecesCard.vue
Normal file
71
frontend/app/components/machine/MachinePiecesCard.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="card-title">Pièces de la machine</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
@click="$emit('toggle-collapse')"
|
||||
:title="collapsed ? 'Déplier toutes les pièces' : 'Replier toutes les pièces'"
|
||||
>
|
||||
<IconLucideChevronRight
|
||||
class="w-5 h-5 transition-transform"
|
||||
:class="collapsed ? 'rotate-0' : 'rotate-90'"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="text-sm">
|
||||
{{ collapsed ? 'Tout déplier' : 'Tout replier' }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="pieces.length === 0" class="text-sm text-gray-500 py-4">
|
||||
Aucune pièce associée à cette machine.
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="piece in pieces" :key="piece.id">
|
||||
<PieceItem
|
||||
:piece="piece"
|
||||
:is-edit-mode="isEditMode"
|
||||
:show-delete="isEditMode"
|
||||
:collapse-all="collapsed"
|
||||
:toggle-token="collapseToggleToken"
|
||||
@update="$emit('update-piece', $event)"
|
||||
@edit="$emit('edit-piece', $event)"
|
||||
@delete="$emit('remove-piece', piece.linkId || piece.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-piece')"
|
||||
>
|
||||
Ajouter une pièce
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
|
||||
defineProps<{
|
||||
pieces: any[]
|
||||
isEditMode: boolean
|
||||
collapsed: boolean
|
||||
collapseToggleToken: number
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'toggle-collapse': []
|
||||
'update-piece': [piece: any]
|
||||
'edit-piece': [piece: any]
|
||||
'add-piece': []
|
||||
'remove-piece': [linkId: string]
|
||||
}>()
|
||||
</script>
|
||||
175
frontend/app/components/machine/MachineProductsCard.vue
Normal file
175
frontend/app/components/machine/MachineProductsCard.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body space-y-4">
|
||||
<DocumentPreviewModal
|
||||
:document="previewDocument"
|
||||
:visible="previewVisible"
|
||||
:documents="previewDocumentList"
|
||||
@close="closePreview"
|
||||
/>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="card-title">Produits associés</h2>
|
||||
<p class="text-xs text-gray-500">
|
||||
Produits sélectionnés directement pour cette machine.
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline" v-if="products.length">
|
||||
{{ products.length }} produit{{ products.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<p class="font-semibold text-base-content">
|
||||
{{ product.name }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="product.groupLabel" class="badge badge-ghost badge-sm">
|
||||
{{ product.groupLabel }}
|
||||
</span>
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
title="Supprimer ce produit"
|
||||
@click="$emit('remove-product', (product.linkId || product.id) as string)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="product.reference" class="text-xs text-base-content/70">
|
||||
<span class="font-medium">Référence :</span>
|
||||
<span class="ml-1">{{ product.reference }}</span>
|
||||
</p>
|
||||
<p v-if="product.supplierLabel" class="text-xs text-base-content/70">
|
||||
<span class="font-medium">Fournisseurs :</span>
|
||||
<span class="ml-1">{{ product.supplierLabel }}</span>
|
||||
</p>
|
||||
<p v-if="product.priceLabel" class="text-xs text-base-content/70">
|
||||
<span class="font-medium">Prix indicatif :</span>
|
||||
<span class="ml-1">{{ product.priceLabel }}</span>
|
||||
</p>
|
||||
|
||||
<!-- Documents liés au produit -->
|
||||
<div v-if="product.documents?.length" class="mt-2 space-y-1">
|
||||
<p class="text-xs font-medium text-base-content/70">Documents :</p>
|
||||
<div
|
||||
v-for="doc in product.documents"
|
||||
:key="doc.id || doc.name"
|
||||
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3 text-xs">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-8 w-6"
|
||||
>
|
||||
<component
|
||||
:is="documentIcon(doc).component"
|
||||
class="h-4 w-4"
|
||||
:class="documentIcon(doc).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-base-content">{{ doc.name }}</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
{{ doc.mimeType || 'Inconnu' }} • {{ formatSize(doc.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(doc)"
|
||||
:title="canPreviewDocument(doc) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="openPreview(doc, product.documents || [])"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
@click="downloadDocument(doc)"
|
||||
>
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
Aucun produit n'a été associé directement à cette machine.
|
||||
</p>
|
||||
|
||||
<button
|
||||
v-if="isEditMode"
|
||||
type="button"
|
||||
class="btn btn-sm md:btn-md btn-primary"
|
||||
@click="$emit('add-product')"
|
||||
>
|
||||
Ajouter un produit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||
import {
|
||||
formatSize,
|
||||
documentIcon,
|
||||
downloadDocument,
|
||||
} from '~/shared/utils/documentDisplayUtils'
|
||||
|
||||
defineProps<{
|
||||
products: Array<{
|
||||
id?: string | null
|
||||
linkId?: string | null
|
||||
name?: string
|
||||
reference?: string | null
|
||||
supplierLabel?: string | null
|
||||
priceLabel?: string | null
|
||||
groupLabel?: string
|
||||
documents?: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
mimeType?: string
|
||||
size?: number
|
||||
fileUrl?: string
|
||||
downloadUrl?: string
|
||||
}>
|
||||
}>
|
||||
isEditMode: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'add-product': []
|
||||
'remove-product': [linkId: string]
|
||||
}>()
|
||||
|
||||
const previewDocument = ref<any>(null)
|
||||
const previewVisible = ref(false)
|
||||
const previewDocumentList = ref<any[]>([])
|
||||
|
||||
const openPreview = (doc: any, docs: any[]) => {
|
||||
previewDocument.value = doc
|
||||
previewDocumentList.value = docs
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
previewDocument.value = null
|
||||
}
|
||||
</script>
|
||||
172
frontend/app/components/model-types/ConversionModal.vue
Normal file
172
frontend/app/components/model-types/ConversionModal.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
Convertir la catégorie
|
||||
</h3>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="checking" class="mt-4 flex items-center gap-2 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Vérification de la conversion…
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-else-if="checkError" class="mt-4 text-sm text-error">
|
||||
{{ checkError }}
|
||||
</div>
|
||||
|
||||
<!-- Blocked state -->
|
||||
<template v-else-if="checkResult && !checkResult.canConvert">
|
||||
<p class="mt-3 text-sm text-base-content/70">
|
||||
La conversion de « {{ modelType?.name }} » est impossible pour les raisons suivantes :
|
||||
</p>
|
||||
<ul class="mt-3 space-y-1">
|
||||
<li
|
||||
v-for="(blocker, i) in checkResult.blockers"
|
||||
:key="i"
|
||||
class="flex items-start gap-2 rounded-lg border border-error/20 bg-error/5 px-3 py-2 text-sm text-error"
|
||||
>
|
||||
<IconLucideCircleX class="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{{ blocker }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- Eligible state -->
|
||||
<template v-else-if="checkResult && checkResult.canConvert">
|
||||
<div class="mt-3 rounded-lg border border-warning/20 bg-warning/5 px-4 py-3">
|
||||
<p class="text-sm font-medium text-warning">
|
||||
{{ directionLabel }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ checkResult.itemCount }} élément(s) seront convertis. Cette opération est irréversible.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="checkResult.names.length > 0"
|
||||
class="mt-3 rounded-xl border border-base-200 bg-base-100"
|
||||
>
|
||||
<p class="px-4 pt-3 text-sm font-medium text-base-content/70">
|
||||
Éléments concernés :
|
||||
</p>
|
||||
<ul class="max-h-48 divide-y divide-base-200 overflow-y-auto px-4 pb-3">
|
||||
<li
|
||||
v-for="(name, i) in checkResult.names"
|
||||
:key="i"
|
||||
class="py-1.5 text-sm text-base-content"
|
||||
>
|
||||
{{ name }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="convertError" class="mt-3 text-sm text-error">
|
||||
{{ convertError }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="modal-action">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:disabled="converting"
|
||||
@click="emit('close')"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
<button
|
||||
v-if="checkResult?.canConvert"
|
||||
type="button"
|
||||
class="btn btn-warning"
|
||||
:disabled="converting"
|
||||
@click="doConvert"
|
||||
>
|
||||
<span v-if="converting" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Convertir
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import IconLucideCircleX from '~icons/lucide/circle-x';
|
||||
import {
|
||||
checkConversion,
|
||||
convertCategory,
|
||||
type ConversionCheck,
|
||||
type ModelType,
|
||||
} from '~/services/modelTypes';
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
modelType: ModelType | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void;
|
||||
(e: 'converted'): void;
|
||||
}>();
|
||||
|
||||
const checking = ref(false);
|
||||
const checkError = ref<string | null>(null);
|
||||
const checkResult = ref<ConversionCheck | null>(null);
|
||||
const converting = ref(false);
|
||||
const convertError = ref<string | null>(null);
|
||||
|
||||
const directionLabel = computed(() => {
|
||||
if (!checkResult.value) return '';
|
||||
return checkResult.value.direction === 'piece_to_component'
|
||||
? 'Conversion : Catégorie de pièce → Catégorie de composant'
|
||||
: 'Conversion : Catégorie de composant → Catégorie de pièce';
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen || !props.modelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
checking.value = true;
|
||||
checkError.value = null;
|
||||
checkResult.value = null;
|
||||
convertError.value = null;
|
||||
|
||||
try {
|
||||
checkResult.value = await checkConversion(props.modelType.id);
|
||||
} catch (err: any) {
|
||||
checkError.value =
|
||||
err?.data?.message || err?.message || 'Erreur lors de la vérification.';
|
||||
} finally {
|
||||
checking.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const doConvert = async () => {
|
||||
if (!props.modelType) return;
|
||||
|
||||
converting.value = true;
|
||||
convertError.value = null;
|
||||
|
||||
try {
|
||||
const result = await convertCategory(props.modelType.id);
|
||||
|
||||
if (!result.success) {
|
||||
convertError.value = result.error || 'La conversion a échoué.';
|
||||
return;
|
||||
}
|
||||
|
||||
emit('converted');
|
||||
} catch (err: any) {
|
||||
convertError.value =
|
||||
err?.data?.message || err?.message || 'Erreur lors de la conversion.';
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
438
frontend/app/components/model-types/ManagementView.vue
Normal file
438
frontend/app/components/model-types/ManagementView.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<main
|
||||
class="mx-auto flex w-full max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8"
|
||||
>
|
||||
<header class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-base-content">{{ headingText }}</h1>
|
||||
<p class="text-base text-base-content/70">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<nav
|
||||
v-if="allowCategorySwitch"
|
||||
class="tabs tabs-boxed inline-flex"
|
||||
role="tablist"
|
||||
aria-label="Catégories"
|
||||
>
|
||||
<button
|
||||
v-for="option in categories"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': option.value === selectedCategory }"
|
||||
role="tab"
|
||||
:aria-selected="option.value === selectedCategory"
|
||||
@click="onCategoryChange(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:rows="items"
|
||||
:loading="loading"
|
||||
:sort="currentSort"
|
||||
:pagination="paginationState"
|
||||
:show-per-page="true"
|
||||
row-key="id"
|
||||
empty-message="Aucune catégorie trouvée."
|
||||
no-results-message="Aucune catégorie ne correspond à votre recherche."
|
||||
@sort="handleSort"
|
||||
@update:current-page="handlePageChange"
|
||||
@update:per-page="handlePerPageChange"
|
||||
>
|
||||
<template #toolbar>
|
||||
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
|
||||
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="search"
|
||||
class="grow min-w-0"
|
||||
placeholder="Rechercher par nom…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="loading"
|
||||
@click="openCreatePage"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
||||
Créer
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template #cell-name="{ row }">
|
||||
<span class="font-medium">{{ row.name }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-notes="{ row }">
|
||||
<span v-if="row.notes" class="block text-sm text-base-content/80 break-words">{{ row.notes }}</span>
|
||||
<span v-else class="text-base-content/50">—</span>
|
||||
</template>
|
||||
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="openRelatedModal(row)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-warning"
|
||||
@click="openConversionModal(row)"
|
||||
>
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="openEditPage(row)">
|
||||
Éditer
|
||||
</button>
|
||||
<button v-if="canEdit" type="button" class="btn btn-ghost btn-xs text-error" @click="confirmDelete(row)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<ConversionModal
|
||||
:open="conversionModalOpen"
|
||||
:model-type="conversionTarget"
|
||||
@close="closeConversionModal"
|
||||
@converted="onConverted"
|
||||
/>
|
||||
|
||||
<RelatedItemsModal
|
||||
:open="relatedModalOpen"
|
||||
:model-type="relatedType"
|
||||
@close="relatedModalOpen = false"
|
||||
@open-edit="openRelatedEdit"
|
||||
/>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch, type Ref } from 'vue'
|
||||
import { useHead, useRouter } from '#imports'
|
||||
import DataTable from '~/components/common/DataTable.vue'
|
||||
import ConversionModal from '~/components/model-types/ConversionModal.vue'
|
||||
import { useUrlState } from '~/composables/useUrlState'
|
||||
import type { DataTableSort } from '~/shared/types/dataTable'
|
||||
import {
|
||||
deleteModelType,
|
||||
listModelTypes,
|
||||
type ModelCategory,
|
||||
type ModelType,
|
||||
type ModelTypeListResponse,
|
||||
} from '~/services/modelTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import { invalidateEntityTypeCache } from '~/composables/useEntityTypes'
|
||||
import IconLucideSearch from '~icons/lucide/search'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
const DEFAULT_DESCRIPTION
|
||||
= 'Gérez les catégories utilisées pour structurer les catalogues de composants, de pièces et de produits. Ajoutez, modifiez ou supprimez des entrées avec tri, recherche et pagination.'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
category: ModelCategory
|
||||
heading: string
|
||||
description?: string
|
||||
allowCategorySwitch?: boolean
|
||||
}>(),
|
||||
{
|
||||
allowCategorySwitch: false,
|
||||
},
|
||||
)
|
||||
|
||||
const selectedCategory = ref<ModelCategory>(props.category)
|
||||
const searchInput = ref('')
|
||||
|
||||
// State synced with URL query params
|
||||
const urlState = useUrlState({
|
||||
q: { default: '' },
|
||||
sort: { default: 'name' },
|
||||
dir: { default: 'asc' },
|
||||
limit: { default: 20, type: 'number' },
|
||||
offset: { default: 0, type: 'number' },
|
||||
}, {
|
||||
onRestore: () => {
|
||||
searchInput.value = urlState.q.value
|
||||
doRefresh()
|
||||
},
|
||||
})
|
||||
const searchTerm = urlState.q
|
||||
const sort = urlState.sort as Ref<'name' | 'createdAt'>
|
||||
const dir = urlState.dir as Ref<'asc' | 'desc'>
|
||||
const limit = urlState.limit
|
||||
const offset = urlState.offset
|
||||
|
||||
// Initialize searchInput from URL
|
||||
searchInput.value = searchTerm.value
|
||||
|
||||
const items = ref<ModelType[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let activeController: AbortController | null = null
|
||||
|
||||
const router = useRouter()
|
||||
const { showError, showSuccess } = useToast()
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const headingText = computed(() => props.heading)
|
||||
const descriptionText = computed(() => props.description ?? DEFAULT_DESCRIPTION)
|
||||
const allowCategorySwitch = computed(() => props.allowCategorySwitch ?? false)
|
||||
|
||||
useHead(() => ({ title: headingText.value }))
|
||||
|
||||
const columns = [
|
||||
{ key: 'name', label: 'Nom', sortable: true },
|
||||
{ key: 'notes', label: 'Notes' },
|
||||
{ key: 'actions', label: 'Actions', align: 'right' as const, width: 'w-48' },
|
||||
]
|
||||
|
||||
const showConvertButton = computed(() =>
|
||||
selectedCategory.value === 'PIECE' || selectedCategory.value === 'COMPONENT',
|
||||
)
|
||||
|
||||
const categories: Array<{ label: string, value: ModelCategory }> = [
|
||||
{ label: 'Composants', value: 'COMPONENT' },
|
||||
{ label: 'Pièces', value: 'PIECE' },
|
||||
{ label: 'Produits', value: 'PRODUCT' },
|
||||
]
|
||||
|
||||
// Sort state for DataTable
|
||||
const currentSort = computed<DataTableSort>(() => ({
|
||||
field: sort.value,
|
||||
direction: dir.value,
|
||||
}))
|
||||
|
||||
const handleSort = (newSort: DataTableSort) => {
|
||||
sort.value = newSort.field as 'name' | 'createdAt'
|
||||
dir.value = newSort.direction as 'asc' | 'desc'
|
||||
offset.value = 0
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
// Pagination: convert offset/limit to page-based for DataTable
|
||||
const currentPage = computed(() => {
|
||||
if (limit.value <= 0) return 1
|
||||
return Math.floor(offset.value / limit.value) + 1
|
||||
})
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (limit.value <= 0) return 1
|
||||
return Math.max(1, Math.ceil(total.value / limit.value))
|
||||
})
|
||||
|
||||
const paginationState = computed(() => ({
|
||||
currentPage: currentPage.value,
|
||||
totalPages: totalPages.value,
|
||||
totalItems: total.value,
|
||||
pageItems: items.value.length,
|
||||
perPageOptions: [20, 50, 100],
|
||||
perPage: limit.value,
|
||||
}))
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
offset.value = (page - 1) * limit.value
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
const handlePerPageChange = (perPage: number) => {
|
||||
limit.value = perPage
|
||||
offset.value = 0
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
const extractErrorMessage = (error: unknown): string => {
|
||||
let raw: string | null = null
|
||||
if (error && typeof error === 'object') {
|
||||
const maybeFetchError = error as {
|
||||
data?: Record<string, unknown>
|
||||
statusMessage?: string
|
||||
message?: string
|
||||
}
|
||||
if (maybeFetchError.data) {
|
||||
const data = maybeFetchError.data
|
||||
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||
else if (typeof data.detail === 'string') raw = data.detail
|
||||
else if (typeof data.message === 'string') raw = data.message
|
||||
else if (Array.isArray(data.message) && data.message.length > 0) raw = data.message[0]
|
||||
else if (typeof data.error === 'string') raw = data.error
|
||||
}
|
||||
if (!raw && typeof maybeFetchError.statusMessage === 'string') raw = maybeFetchError.statusMessage
|
||||
if (!raw && typeof maybeFetchError.message === 'string') raw = maybeFetchError.message
|
||||
}
|
||||
return humanizeError(raw)
|
||||
}
|
||||
|
||||
const doRefresh = async ({ resetOffset = false }: { resetOffset?: boolean } = {}) => {
|
||||
if (resetOffset) offset.value = 0
|
||||
|
||||
if (activeController) activeController.abort()
|
||||
const controller = new AbortController()
|
||||
activeController = controller
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response: ModelTypeListResponse = await listModelTypes(
|
||||
{
|
||||
q: searchTerm.value || undefined,
|
||||
category: selectedCategory.value,
|
||||
sort: sort.value,
|
||||
dir: dir.value,
|
||||
limit: limit.value,
|
||||
offset: offset.value,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
)
|
||||
items.value = response.items
|
||||
total.value = response.total
|
||||
offset.value = response.offset
|
||||
limit.value = response.limit
|
||||
}
|
||||
catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') return
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
finally {
|
||||
if (activeController === controller) {
|
||||
loading.value = false
|
||||
activeController = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.category,
|
||||
(value) => {
|
||||
if (value !== selectedCategory.value) {
|
||||
selectedCategory.value = value
|
||||
doRefresh({ resetOffset: true })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const onCategoryChange = (value: ModelCategory) => {
|
||||
if (!props.allowCategorySwitch) return
|
||||
if (selectedCategory.value !== value) {
|
||||
selectedCategory.value = value
|
||||
doRefresh({ resetOffset: true })
|
||||
}
|
||||
}
|
||||
|
||||
const resolveCategoryBasePath = (category: ModelCategory) => {
|
||||
if (category === 'COMPONENT') return '/component-category'
|
||||
if (category === 'PIECE') return '/piece-category'
|
||||
return '/product-category'
|
||||
}
|
||||
|
||||
const openCreatePage = () => {
|
||||
const basePath = resolveCategoryBasePath(selectedCategory.value)
|
||||
router.push(`${basePath}/new`).catch(() => {
|
||||
showError('Navigation impossible vers la page de création.')
|
||||
})
|
||||
}
|
||||
|
||||
const openEditPage = (item: ModelType) => {
|
||||
const category = item.category ?? selectedCategory.value
|
||||
const basePath = resolveCategoryBasePath(category)
|
||||
router.push(`${basePath}/${item.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la page d'édition.")
|
||||
})
|
||||
}
|
||||
|
||||
const { confirm } = useConfirm()
|
||||
|
||||
const confirmDelete = async (item: ModelType) => {
|
||||
const confirmed = await confirm({
|
||||
message: 'Supprimer ce type ? Cette action est irréversible.',
|
||||
})
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await deleteModelType(item.id)
|
||||
invalidateEntityTypeCache(item.category)
|
||||
showSuccess(`Type « ${item.name} » supprimé avec succès.`)
|
||||
if (items.value.length === 1 && offset.value >= limit.value) {
|
||||
offset.value = Math.max(0, offset.value - limit.value)
|
||||
}
|
||||
await doRefresh()
|
||||
}
|
||||
catch (error) {
|
||||
showError(extractErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const relatedModalOpen = ref(false)
|
||||
const relatedType = ref<ModelType | null>(null)
|
||||
|
||||
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||
if (category === 'COMPONENT') return '/component'
|
||||
if (category === 'PIECE') return '/pieces'
|
||||
return '/product'
|
||||
}
|
||||
|
||||
const openRelatedModal = (item: ModelType) => {
|
||||
relatedType.value = item
|
||||
relatedModalOpen.value = true
|
||||
}
|
||||
|
||||
const openRelatedEdit = (entry: { id: string }) => {
|
||||
const current = relatedType.value
|
||||
if (!current) return
|
||||
const basePath = resolveRelatedEditBasePath(current.category)
|
||||
relatedModalOpen.value = false
|
||||
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la fiche d'édition.")
|
||||
})
|
||||
}
|
||||
|
||||
const conversionModalOpen = ref(false)
|
||||
const conversionTarget = ref<ModelType | null>(null)
|
||||
|
||||
const openConversionModal = (item: ModelType) => {
|
||||
conversionTarget.value = item
|
||||
conversionModalOpen.value = true
|
||||
}
|
||||
|
||||
const closeConversionModal = () => {
|
||||
conversionModalOpen.value = false
|
||||
}
|
||||
|
||||
const onConverted = () => {
|
||||
conversionModalOpen.value = false
|
||||
invalidateEntityTypeCache('PIECE')
|
||||
invalidateEntityTypeCache('COMPONENT')
|
||||
showSuccess('Catégorie convertie avec succès.')
|
||||
doRefresh()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
searchTerm.value = value.trim()
|
||||
doRefresh({ resetOffset: true })
|
||||
}, 300)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
doRefresh()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
if (activeController) activeController.abort()
|
||||
})
|
||||
</script>
|
||||
405
frontend/app/components/model-types/ModelTypeForm.vue
Normal file
405
frontend/app/components/model-types/ModelTypeForm.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<form class="space-y-8" @submit.prevent="handleSubmit">
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<label class="label" for="model-type-name">
|
||||
<span class="label-text">Nom *</span>
|
||||
</label>
|
||||
<input
|
||||
id="model-type-name"
|
||||
ref="nameInput"
|
||||
v-model.trim="form.name"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
name="name"
|
||||
minlength="2"
|
||||
maxlength="120"
|
||||
required
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="model-type-category">
|
||||
<span class="label-text">Catégorie *</span>
|
||||
</label>
|
||||
<select
|
||||
id="model-type-category"
|
||||
v-model="form.category"
|
||||
class="select select-bordered w-full"
|
||||
name="category"
|
||||
required
|
||||
:disabled="lockCategory || isReadonly"
|
||||
>
|
||||
<option value="COMPONENT">Composants</option>
|
||||
<option value="PIECE">Pièces</option>
|
||||
<option value="PRODUCT">Produits</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label" for="model-type-notes">
|
||||
<span class="label-text">Notes</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="model-type-notes"
|
||||
v-model.trim="form.notes"
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="4"
|
||||
name="notes"
|
||||
maxlength="2000"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<header>
|
||||
<h3 class="text-lg font-semibold text-base-content">Structure du squelette</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
Définissez la structure canonique appliquée lors de la création des composants ou pièces de cette catégorie.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="structureLoading"
|
||||
class="flex items-center justify-center rounded-lg border border-dashed border-base-300 py-12"
|
||||
>
|
||||
<span class="loading loading-spinner loading-lg" aria-hidden="true"></span>
|
||||
<span class="ml-3 text-sm text-base-content/70">Chargement du squelette…</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="form.category === 'COMPONENT'"
|
||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||
>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ componentStructurePreview }}</span>
|
||||
</p>
|
||||
<ComponentModelStructureEditor
|
||||
v-model="componentStructure"
|
||||
:allow-subcomponents="allowComponentSubcomponents"
|
||||
:max-subcomponent-depth="componentSubcomponentMaxDepth"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="form.category === 'PIECE'"
|
||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||
>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="pieceStructure" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3 rounded-lg border border-base-300 p-4"
|
||||
>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="productStructure" />
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<ReferenceFormulaBuilder
|
||||
v-if="form.category === 'PIECE' || form.category === 'COMPONENT'"
|
||||
v-model="form.referenceFormula"
|
||||
:custom-fields="formulaBuilderCustomFields"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
|
||||
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||
Annuler
|
||||
</button>
|
||||
<button v-if="!isReadonly" type="submit" class="btn btn-primary" :disabled="isSubmitDisabled">
|
||||
<span v-if="saving" class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
|
||||
import PieceModelStructureEditor from '~/components/PieceModelStructureEditor.vue'
|
||||
import {
|
||||
clonePieceStructure,
|
||||
cloneProductStructure,
|
||||
cloneStructure,
|
||||
defaultPieceStructure,
|
||||
defaultProductStructure,
|
||||
defaultStructure,
|
||||
formatPieceStructurePreview,
|
||||
formatProductStructurePreview,
|
||||
formatStructurePreview,
|
||||
normalizePieceStructureForSave,
|
||||
normalizeProductStructureForSave,
|
||||
normalizeStructureForEditor,
|
||||
normalizeStructureForSave,
|
||||
} from '~/shared/modelUtils'
|
||||
import type { ModelCategory, ModelTypePayload } from '~/services/modelTypes'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
mode: 'create' | 'edit'
|
||||
initialCategory: ModelCategory
|
||||
initialData?: Partial<ModelTypePayload> | null
|
||||
saving?: boolean
|
||||
lockCategory?: boolean
|
||||
structureLoading?: boolean
|
||||
allowComponentSubcomponents?: boolean
|
||||
componentSubcomponentMaxDepth?: number
|
||||
readonly?: boolean
|
||||
}>(), {
|
||||
initialData: null,
|
||||
saving: false,
|
||||
lockCategory: false,
|
||||
structureLoading: false,
|
||||
allowComponentSubcomponents: true,
|
||||
componentSubcomponentMaxDepth: 1,
|
||||
readonly: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit', payload: ModelTypePayload): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const lockCategory = computed(() => props.lockCategory ?? false)
|
||||
const structureLoading = computed(() => props.structureLoading ?? false)
|
||||
const saving = computed(() => props.saving ?? false)
|
||||
const allowComponentSubcomponents = computed(() => props.allowComponentSubcomponents !== false)
|
||||
const componentSubcomponentMaxDepth = computed(() =>
|
||||
typeof props.componentSubcomponentMaxDepth === 'number'
|
||||
? props.componentSubcomponentMaxDepth
|
||||
: 1,
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly === true)
|
||||
|
||||
const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
||||
name: '',
|
||||
code: '',
|
||||
category: props.initialCategory,
|
||||
notes: '',
|
||||
structure: undefined,
|
||||
referenceFormula: null,
|
||||
})
|
||||
|
||||
const formulaBuilderCustomFields = computed(() => {
|
||||
if (form.category === 'PIECE') {
|
||||
const fields = pieceStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
}
|
||||
if (form.category === 'COMPONENT') {
|
||||
const fields = componentStructure.value?.customFields
|
||||
return Array.isArray(fields) ? fields : []
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const extractFormulaFields = (formula: string | null | undefined): string[] => {
|
||||
if (!formula) return []
|
||||
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
|
||||
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
|
||||
}
|
||||
|
||||
const errors = reactive<{ name?: string }>({})
|
||||
const nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const componentStructure = ref(normalizeStructureForEditor(defaultStructure()))
|
||||
const pieceStructure = ref(normalizePieceStructureForSave(defaultPieceStructure()))
|
||||
const productStructure = ref(normalizeProductStructureForSave(defaultProductStructure()))
|
||||
|
||||
const generateCodeFromName = (name: string) => {
|
||||
const fallback = 'type'
|
||||
if (!name) {
|
||||
return fallback
|
||||
}
|
||||
return name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036F]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-+/g, '-') || fallback
|
||||
}
|
||||
|
||||
const resetStructures = (incomingStructure: ModelTypePayload['structure'], category: ModelCategory) => {
|
||||
if (category === 'COMPONENT') {
|
||||
componentStructure.value = normalizeStructureForEditor(
|
||||
incomingStructure && props.initialData?.category === 'COMPONENT'
|
||||
? incomingStructure
|
||||
: defaultStructure(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (category === 'PIECE') {
|
||||
pieceStructure.value = normalizePieceStructureForSave(
|
||||
incomingStructure && props.initialData?.category === 'PIECE'
|
||||
? incomingStructure
|
||||
: defaultPieceStructure(),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
productStructure.value = normalizeProductStructureForSave(
|
||||
incomingStructure && props.initialData?.category === 'PRODUCT'
|
||||
? cloneProductStructure(incomingStructure)
|
||||
: defaultProductStructure(),
|
||||
)
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
const incoming = props.initialData ?? {}
|
||||
form.name = typeof incoming.name === 'string' ? incoming.name : ''
|
||||
form.code = typeof incoming.code === 'string' && incoming.code
|
||||
? incoming.code
|
||||
: generateCodeFromName(form.name)
|
||||
form.category = incoming.category ?? props.initialCategory
|
||||
const incomingRecord = incoming as Record<string, unknown>
|
||||
form.notes = typeof incoming.notes === 'string'
|
||||
? incoming.notes
|
||||
: typeof incomingRecord.description === 'string'
|
||||
? incomingRecord.description
|
||||
: ''
|
||||
|
||||
errors.name = undefined
|
||||
|
||||
const incomingAny = incoming as Record<string, unknown>
|
||||
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
|
||||
|
||||
resetStructures(incoming.structure, form.category)
|
||||
}
|
||||
|
||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || isReadonly.value)
|
||||
|
||||
const validate = () => {
|
||||
errors.name = undefined
|
||||
|
||||
const trimmedName = form.name.trim()
|
||||
if (trimmedName.length < 2) {
|
||||
errors.name = 'Le nom doit contenir au moins 2 caractères.'
|
||||
}
|
||||
if (trimmedName.length > 120) {
|
||||
errors.name = 'Le nom ne peut pas dépasser 120 caractères.'
|
||||
}
|
||||
|
||||
return !errors.name
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isReadonly.value) return
|
||||
if (!validate()) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedName = form.name.trim()
|
||||
const resolvedCode = form.code?.trim()
|
||||
? form.code.trim()
|
||||
: generateCodeFromName(trimmedName)
|
||||
|
||||
const common = {
|
||||
name: trimmedName,
|
||||
code: resolvedCode,
|
||||
notes: form.notes?.trim() ? form.notes.trim() : undefined,
|
||||
}
|
||||
|
||||
if (form.category === 'COMPONENT') {
|
||||
const formula = form.referenceFormula || null
|
||||
const requiredFields = extractFormulaFields(formula)
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'COMPONENT',
|
||||
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
|
||||
referenceFormula: formula,
|
||||
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||
} as ModelTypePayload)
|
||||
return
|
||||
}
|
||||
|
||||
if (form.category === 'PIECE') {
|
||||
const formula = form.referenceFormula || null
|
||||
const requiredFields = extractFormulaFields(formula)
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'PIECE',
|
||||
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
||||
referenceFormula: formula,
|
||||
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||
} as ModelTypePayload)
|
||||
return
|
||||
}
|
||||
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'PRODUCT',
|
||||
structure: normalizeProductStructureForSave(cloneProductStructure(productStructure.value)),
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.initialData,
|
||||
() => {
|
||||
resetForm()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.name,
|
||||
(value) => {
|
||||
if (props.mode === 'create') {
|
||||
form.code = generateCodeFromName(value)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.initialCategory,
|
||||
() => {
|
||||
if (!props.initialData) {
|
||||
form.category = props.initialCategory
|
||||
resetStructures(undefined, form.category)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => form.category,
|
||||
(category, previous) => {
|
||||
if (category === previous) {
|
||||
return
|
||||
}
|
||||
|
||||
if (category === 'COMPONENT') {
|
||||
componentStructure.value = normalizeStructureForEditor(defaultStructure())
|
||||
}
|
||||
|
||||
if (category === 'PIECE') {
|
||||
pieceStructure.value = normalizePieceStructureForSave(defaultPieceStructure())
|
||||
}
|
||||
|
||||
if (category === 'PRODUCT') {
|
||||
productStructure.value = normalizeProductStructureForSave(defaultProductStructure())
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const componentStructurePreview = computed(() => formatStructurePreview(componentStructure.value))
|
||||
const pieceStructurePreview = computed(() => formatPieceStructurePreview(pieceStructure.value))
|
||||
const productStructurePreview = computed(() => formatProductStructurePreview(productStructure.value))
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => nameInput.value?.focus())
|
||||
})
|
||||
</script>
|
||||
115
frontend/app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
115
frontend/app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<header>
|
||||
<h3 class="text-lg font-semibold text-base-content">Génération de référence automatique</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
Cliquez sur un champ pour l'insérer dans la formule. Vous pouvez aussi taper du texte libre (séparateurs, préfixes…).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="rounded-lg border border-base-300 p-4 space-y-4">
|
||||
<div v-if="fieldNames.length" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="name in fieldNames"
|
||||
:key="name"
|
||||
type="button"
|
||||
class="btn btn-xs btn-outline btn-primary font-mono"
|
||||
:disabled="disabled"
|
||||
@click="insertField(name)"
|
||||
>
|
||||
{{ name }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-sm text-base-content/50 italic">
|
||||
Aucun champ personnalisé défini dans la structure.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="label" for="reference-formula">
|
||||
<span class="label-text">Formule</span>
|
||||
</label>
|
||||
<input
|
||||
id="reference-formula"
|
||||
ref="inputRef"
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
class="input input-bordered w-full font-mono"
|
||||
placeholder="Ex: SNU {serie}-{diametre}/{type}"
|
||||
:disabled="disabled"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Laissez vide si ce type n'utilise pas de référence automatique.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="modelValue" class="rounded bg-base-200 px-3 py-2 text-sm">
|
||||
<span class="text-base-content/70">Aperçu :</span>
|
||||
<span class="ml-1 font-mono font-semibold">{{ preview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
interface CustomField {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null | undefined
|
||||
customFields: CustomField[]
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | null): void
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const fieldNames = computed(() =>
|
||||
props.customFields.map(f => f.name).filter((n): n is string => Boolean(n)),
|
||||
)
|
||||
|
||||
const previewExamples: Record<string, string> = {
|
||||
text: 'VALEUR',
|
||||
number: '123',
|
||||
select: 'OPTION',
|
||||
boolean: 'OUI',
|
||||
date: '2026-01-01',
|
||||
}
|
||||
|
||||
const preview = computed(() => {
|
||||
if (!props.modelValue) return ''
|
||||
const fieldMap = new Map<string, string>()
|
||||
for (const f of props.customFields) {
|
||||
if (f.name) {
|
||||
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
|
||||
}
|
||||
}
|
||||
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
|
||||
})
|
||||
|
||||
const insertField = (fieldName: string) => {
|
||||
const placeholder = `{${fieldName}}`
|
||||
const input = inputRef.value
|
||||
const current = props.modelValue ?? ''
|
||||
if (!input) {
|
||||
emit('update:modelValue', current + placeholder)
|
||||
return
|
||||
}
|
||||
const start = input.selectionStart ?? current.length
|
||||
const end = input.selectionEnd ?? start
|
||||
const updated = current.slice(0, start) + placeholder + current.slice(end)
|
||||
emit('update:modelValue', updated)
|
||||
nextTick(() => {
|
||||
const newPos = start + placeholder.length
|
||||
input.focus()
|
||||
input.setSelectionRange(newPos, newPos)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
182
frontend/app/components/model-types/RelatedItemsModal.vue
Normal file
182
frontend/app/components/model-types/RelatedItemsModal.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<dialog class="modal" :class="{ 'modal-open': open }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
{{ modalTitle }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ modalSubtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||
<div v-if="loading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement des éléments liés…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="px-4 py-6 text-sm text-error">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
class="px-4 py-6 text-sm text-base-content/60"
|
||||
>
|
||||
Aucun élément lié à cette catégorie.
|
||||
</div>
|
||||
|
||||
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
||||
<li
|
||||
v-for="entry in items"
|
||||
:key="entry.id"
|
||||
class="px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
@click="onOpenEdit(entry)"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="emit('close')">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { extractCollection } from '~/shared/utils/apiHelpers'
|
||||
import { humanizeError } from '~/shared/utils/errorMessages'
|
||||
import type { ModelCategory, ModelType } from '~/services/modelTypes'
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string
|
||||
name: string
|
||||
reference?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
modelType: ModelType | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
'open-edit': [entry: RelatedEntry]
|
||||
}>()
|
||||
|
||||
const { get } = useApi()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const items = ref<RelatedEntry[]>([])
|
||||
|
||||
const categoryLabels: Record<ModelCategory, { plural: string, singular: string }> = {
|
||||
COMPONENT: { plural: 'composants', singular: 'composant' },
|
||||
PIECE: { plural: 'pièces', singular: 'pièce' },
|
||||
PRODUCT: { plural: 'produits', singular: 'produit' },
|
||||
}
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (!props.modelType) return 'Éléments liés'
|
||||
return `Éléments liés à « ${props.modelType.name} »`
|
||||
})
|
||||
|
||||
const modalSubtitle = computed(() => {
|
||||
if (!props.modelType) return ''
|
||||
const labels = categoryLabels[props.modelType.category] ?? categoryLabels.COMPONENT
|
||||
const count = items.value.length
|
||||
if (loading.value) return `Chargement des ${labels.plural}…`
|
||||
if (count === 0) return `Aucun ${labels.singular} lié.`
|
||||
if (count === 1) return `1 ${labels.singular} lié.`
|
||||
return `${count} ${labels.plural} liés.`
|
||||
})
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === 'COMPONENT') return { endpoint: '/composants', filterKey: 'typeComposant' }
|
||||
if (category === 'PIECE') return { endpoint: '/pieces', filterKey: 'typePiece' }
|
||||
return { endpoint: '/products', filterKey: 'typeProduct' }
|
||||
}
|
||||
|
||||
const mapRelatedEntry = (item: unknown): RelatedEntry | null => {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const record = item as Record<string, unknown>
|
||||
if (typeof record.id !== 'string') return null
|
||||
const name = typeof record.name === 'string' && record.name.trim() ? record.name : 'Sans nom'
|
||||
const reference
|
||||
= typeof record.reference === 'string' && record.reference.trim()
|
||||
? record.reference
|
||||
: typeof record.code === 'string' && record.code.trim()
|
||||
? record.code
|
||||
: null
|
||||
return { id: record.id, name, reference }
|
||||
}
|
||||
|
||||
const loadRelatedItems = async (modelType: ModelType) => {
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(modelType.category)
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '200')
|
||||
params.set(filterKey, `/api/model_types/${modelType.id}`)
|
||||
params.set('order[name]', 'asc')
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
items.value = []
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger les éléments liés.'
|
||||
return
|
||||
}
|
||||
const collection = extractCollection(result.data)
|
||||
items.value = collection
|
||||
.map(mapRelatedEntry)
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry))
|
||||
}
|
||||
catch (err) {
|
||||
let raw: string | null = null
|
||||
if (err && typeof err === 'object') {
|
||||
const e = err as { data?: Record<string, unknown>, statusMessage?: string, message?: string }
|
||||
if (e.data) {
|
||||
const data = e.data
|
||||
if (typeof data['hydra:description'] === 'string') raw = data['hydra:description']
|
||||
else if (typeof data.detail === 'string') raw = data.detail
|
||||
else if (typeof data.message === 'string') raw = data.message
|
||||
else if (typeof data.error === 'string') raw = data.error
|
||||
}
|
||||
if (!raw && typeof e.statusMessage === 'string') raw = e.statusMessage
|
||||
if (!raw && typeof e.message === 'string') raw = e.message
|
||||
}
|
||||
error.value = humanizeError(raw)
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onOpenEdit = (entry: RelatedEntry) => {
|
||||
emit('open-edit', entry)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(isOpen) => {
|
||||
if (isOpen && props.modelType) {
|
||||
void loadRelatedItems(props.modelType)
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
190
frontend/app/components/model-types/Table.vue
Normal file
190
frontend/app/components/model-types/Table.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<section class="space-y-4" aria-live="polite">
|
||||
<header class="flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-base-content">Catégories enregistrées</h2>
|
||||
<p class="text-sm text-base-content/70">
|
||||
{{ totalLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="loading" class="text-sm text-info flex items-center gap-2">
|
||||
<span class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Chargement en cours…
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div v-if="loading && items.length === 0" class="space-y-3" role="status" aria-live="polite">
|
||||
<div
|
||||
v-for="index in 3"
|
||||
:key="index"
|
||||
class="rounded-xl border border-base-200 bg-base-200/70 animate-pulse h-24"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length === 0" class="rounded-xl border border-dashed border-base-200 p-10 text-center">
|
||||
<IconLucideInbox class="mx-auto h-12 w-12 text-base-content/30" aria-hidden="true" />
|
||||
<h3 class="mt-4 text-lg font-medium text-base-content">Aucune catégorie trouvée</h3>
|
||||
<p class="text-sm text-base-content/70">Ajustez votre recherche ou créez une nouvelle catégorie.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="hidden sm:block overflow-x-auto">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr class="text-base-content/70">
|
||||
<th scope="col">Nom</th>
|
||||
<th scope="col">Notes</th>
|
||||
<th scope="col" class="w-48 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in items" :key="item.id">
|
||||
<td class="font-medium">{{ item.name }}</td>
|
||||
<td class="max-w-xs align-middle">
|
||||
<span v-if="item.notes" class="block text-sm text-base-content/80 break-words">{{ item.notes }}</span>
|
||||
<span v-else class="text-base-content/50">—</span>
|
||||
</td>
|
||||
<td class="text-right space-x-2">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-warning"
|
||||
@click="emit('convert', item)"
|
||||
>
|
||||
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||
Supprimer
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:hidden">
|
||||
<article
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
class="rounded-xl border border-base-200 bg-base-100 p-4 shadow-sm"
|
||||
>
|
||||
<header class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-base-content">{{ item.name }}</h3>
|
||||
<p class="text-sm text-base-content/60">{{ categoryLabel(item.category) }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
|
||||
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
||||
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button
|
||||
v-if="canEdit && showConvertButton"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-warning"
|
||||
@click="emit('convert', item)"
|
||||
>
|
||||
<IconLucideArrowLeftRight class="h-4 w-4" aria-hidden="true" />
|
||||
Convertir
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
<button v-if="canEdit" type="button" class="btn btn-ghost btn-sm text-error" @click="emit('delete', item)">
|
||||
Supprimer
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-wrap items-center justify-between gap-3" aria-label="Pagination">
|
||||
<span class="text-sm text-base-content/70">
|
||||
Page {{ currentPage }} sur {{ totalPages }}
|
||||
</span>
|
||||
<div class="join">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm join-item"
|
||||
:disabled="!canGoPrevious"
|
||||
@click="emit('update:offset', Math.max(0, offset - limit))"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm join-item"
|
||||
:disabled="!canGoNext"
|
||||
@click="emit('update:offset', offset + limit)"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import IconLucideInbox from '~icons/lucide/inbox';
|
||||
import IconLucideArrowLeftRight from '~icons/lucide/arrow-left-right';
|
||||
import type { ModelType, ModelCategory } from '~/services/modelTypes';
|
||||
|
||||
const props = defineProps<{
|
||||
items: ModelType[];
|
||||
loading: boolean;
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
category?: ModelCategory;
|
||||
canEdit?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'related', item: ModelType): void;
|
||||
(e: 'edit', item: ModelType): void;
|
||||
(e: 'delete', item: ModelType): void;
|
||||
(e: 'convert', item: ModelType): void;
|
||||
(e: 'update:offset', offset: number): void;
|
||||
}>();
|
||||
|
||||
const showConvertButton = computed(() =>
|
||||
props.category === 'PIECE' || props.category === 'COMPONENT',
|
||||
);
|
||||
|
||||
const categoryDictionary: Record<ModelCategory, string> = {
|
||||
COMPONENT: 'Composants',
|
||||
PIECE: 'Pièces',
|
||||
PRODUCT: 'Produits',
|
||||
};
|
||||
|
||||
const categoryLabel = (category: ModelCategory) => categoryDictionary[category] ?? category;
|
||||
|
||||
const currentPage = computed(() => {
|
||||
if (props.limit <= 0) return 1;
|
||||
return Math.floor(props.offset / props.limit) + 1;
|
||||
});
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (props.limit <= 0) return 1;
|
||||
return Math.max(1, Math.ceil(props.total / props.limit));
|
||||
});
|
||||
|
||||
const canGoPrevious = computed(() => props.offset > 0);
|
||||
const canGoNext = computed(() => props.offset + props.limit < props.total);
|
||||
|
||||
const totalLabel = computed(() => {
|
||||
if (props.total === 0) return 'Aucun résultat';
|
||||
if (props.total === 1) return '1 catégorie trouvée';
|
||||
return `${props.total} catégories trouvées`;
|
||||
});
|
||||
</script>
|
||||
116
frontend/app/components/model-types/Toolbar.vue
Normal file
116
frontend/app/components/model-types/Toolbar.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<nav
|
||||
v-if="displayCategoryTabs"
|
||||
class="tabs tabs-boxed inline-flex"
|
||||
role="tablist"
|
||||
aria-label="Catégories"
|
||||
>
|
||||
<button
|
||||
v-for="option in categories"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ 'tab-active': option.value === category }"
|
||||
role="tab"
|
||||
:aria-selected="option.value === category"
|
||||
@click="emit('update:category', option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<label class="input input-bordered flex items-center gap-2 w-full sm:w-72" :aria-busy="loading">
|
||||
<IconLucideSearch class="w-4 h-4" aria-hidden="true" />
|
||||
<input
|
||||
:value="search"
|
||||
type="search"
|
||||
class="grow min-w-0"
|
||||
placeholder="Rechercher par nom…"
|
||||
autocomplete="off"
|
||||
@input="onSearch"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-base-content/70" for="model-type-sort">Trier par</label>
|
||||
<select
|
||||
id="model-type-sort"
|
||||
class="select select-bordered select-sm"
|
||||
:value="sort"
|
||||
@change="emit('update:sort', ($event.target as HTMLSelectElement).value as SortField)"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm font-medium text-base-content/70" for="model-type-dir">Ordre</label>
|
||||
<select
|
||||
id="model-type-dir"
|
||||
class="select select-bordered select-sm"
|
||||
:value="dir"
|
||||
@change="emit('update:dir', ($event.target as HTMLSelectElement).value as SortDirection)"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary self-start"
|
||||
:disabled="loading"
|
||||
@click="emit('create')"
|
||||
>
|
||||
<IconLucidePlus class="w-4 h-4" aria-hidden="true" />
|
||||
Créer
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import IconLucidePlus from '~icons/lucide/plus';
|
||||
import IconLucideSearch from '~icons/lucide/search';
|
||||
import type { ModelCategory } from '~/services/modelTypes';
|
||||
|
||||
type SortField = 'name' | 'createdAt';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
const props = defineProps<{
|
||||
category: ModelCategory;
|
||||
search: string;
|
||||
sort: SortField;
|
||||
dir: SortDirection;
|
||||
loading?: boolean;
|
||||
showCategoryTabs?: boolean;
|
||||
canEdit?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:category', value: ModelCategory): void;
|
||||
(e: 'update:search', value: string): void;
|
||||
(e: 'update:sort', value: SortField): void;
|
||||
(e: 'update:dir', value: SortDirection): void;
|
||||
(e: 'create'): void;
|
||||
}>();
|
||||
|
||||
const categories: Array<{ label: string; value: ModelCategory }> = [
|
||||
{ label: 'Composants', value: 'COMPONENT' },
|
||||
{ label: 'Pièces', value: 'PIECE' },
|
||||
{ label: 'Produits', value: 'PRODUCT' },
|
||||
];
|
||||
|
||||
const onSearch = (event: Event) => {
|
||||
emit('update:search', (event.target as HTMLInputElement).value);
|
||||
};
|
||||
|
||||
const loading = computed(() => props.loading ?? false);
|
||||
const displayCategoryTabs = computed(() => props.showCategoryTabs ?? true);
|
||||
</script>
|
||||
85
frontend/app/components/sites/SiteCard.vue
Normal file
85
frontend/app/components/sites/SiteCard.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
class="card site-card shadow-md hover:shadow-xl transition-shadow overflow-hidden"
|
||||
:style="{
|
||||
borderTop: site.color ? `4px solid ${site.color}` : '4px solid transparent',
|
||||
background: site.color ? `linear-gradient(160deg, ${site.color}30 0%, ${site.color}08 40%, var(--color-base-100) 100%)` : undefined,
|
||||
}"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="card-title text-lg text-base-content">
|
||||
{{ site.name }}
|
||||
</h3>
|
||||
<div
|
||||
class="badge font-bold"
|
||||
:style="site.color ? { backgroundColor: site.color + '30', color: site.color, borderColor: site.color + '50' } : {}"
|
||||
:class="!site.color ? 'badge-primary' : ''"
|
||||
>
|
||||
{{ machineCount }} machines
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-center gap-2 text-base-content/80">
|
||||
<IconLucideUser class="w-4 h-4 text-primary" aria-hidden="true" />
|
||||
<span class="font-medium">{{ site.contactName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-base-content/60">
|
||||
<IconLucidePhone class="w-4 h-4 text-secondary" aria-hidden="true" />
|
||||
<span>{{ formattedContactPhone }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-2 text-base-content/60">
|
||||
<IconLucideMapPin class="w-4 h-4 text-accent mt-1" aria-hidden="true" />
|
||||
<span>
|
||||
{{ site.contactAddress }}<br>
|
||||
{{ site.contactPostalCode }} {{ site.contactCity }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-base-content/60">
|
||||
<IconLucideFactory class="w-4 h-4 text-blue-500" aria-hidden="true" />
|
||||
<span>{{ machineCount }} machine(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-sm btn-outline" @click="emit('edit', site)">
|
||||
{{ canEdit ? 'Modifier' : 'Consulter' }}
|
||||
</button>
|
||||
<button v-if="canEdit" class="btn btn-sm btn-error" @click="emit('delete', site)">
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import IconLucideFactory from '~icons/lucide/factory'
|
||||
import IconLucideMapPin from '~icons/lucide/map-pin'
|
||||
import IconLucidePhone from '~icons/lucide/phone'
|
||||
import IconLucideUser from '~icons/lucide/user'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
|
||||
const { canEdit } = usePermissions()
|
||||
|
||||
const props = defineProps({
|
||||
site: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['edit', 'delete'])
|
||||
|
||||
const machineCount = computed(() => props.site?.machines?.length || 0)
|
||||
const formattedContactPhone = computed(() => {
|
||||
const value = props.site?.contactPhone ?? ''
|
||||
const formatted = formatPhone(value)
|
||||
return formatted || value || '—'
|
||||
})
|
||||
</script>
|
||||
132
frontend/app/components/sites/SiteContactFormFields.vue
Normal file
132
frontend/app/components/sites/SiteContactFormFields.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du contact</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="contactName"
|
||||
type="text"
|
||||
placeholder="Nom et prénom"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FieldPhone v-model="contactPhone" :disabled="disabled" required />
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Adresse</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="contactAddress"
|
||||
type="text"
|
||||
placeholder="Adresse complète"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Code postal</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="contactPostalCode"
|
||||
type="text"
|
||||
placeholder="Code postal"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Ville</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="contactCity"
|
||||
type="text"
|
||||
placeholder="Ville"
|
||||
class="input input-bordered"
|
||||
:disabled="disabled"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
|
||||
type SiteForm = {
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactAddress: string
|
||||
contactPostalCode: string
|
||||
contactCity: string
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
form: {
|
||||
type: Object as PropType<SiteForm>,
|
||||
required: true,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const form = toRef(props, 'form')
|
||||
|
||||
const contactName = computed({
|
||||
get: () => form.value.contactName,
|
||||
set: (value: string) => {
|
||||
form.value.contactName = value
|
||||
},
|
||||
})
|
||||
|
||||
const contactPhone = computed({
|
||||
get: () => form.value.contactPhone,
|
||||
set: (value: string) => {
|
||||
form.value.contactPhone = value
|
||||
},
|
||||
})
|
||||
|
||||
const contactAddress = computed({
|
||||
get: () => form.value.contactAddress,
|
||||
set: (value: string) => {
|
||||
form.value.contactAddress = value
|
||||
},
|
||||
})
|
||||
|
||||
const contactPostalCode = computed({
|
||||
get: () => form.value.contactPostalCode,
|
||||
set: (value: string) => {
|
||||
form.value.contactPostalCode = value
|
||||
},
|
||||
})
|
||||
|
||||
const contactCity = computed({
|
||||
get: () => form.value.contactCity,
|
||||
set: (value: string) => {
|
||||
form.value.contactCity = value
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Bloc de formulaire partagé pour la saisie/édition des informations de contact d'un site.
|
||||
Utilisation :
|
||||
<SiteContactFormFields :form="siteForm" />
|
||||
-->
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user