Compare commits

...

18 Commits

Author SHA1 Message Date
gitea-actions
79077c7bbd chore: bump version to v0.0.88
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m23s
2026-04-24 07:53:13 +00:00
f05fcc5c15 feat: inventaire bovins (!49)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #49
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-24 07:53:06 +00:00
gitea-actions
023d71381e chore: bump version to v0.0.87
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m18s
2026-04-22 15:27:58 +00:00
e2695335e7 fix : masquer le bouton Ajouter sur la page case quand l'utilisateur n'est pas admin
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:26:49 +02:00
gitea-actions
91152c0ed8 chore: bump version to v0.0.86
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m22s
2026-04-22 13:26:05 +00:00
1b4764878e feat: ajout du composant datatable sur tous les écrans (!48)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #48
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-22 13:25:57 +00:00
gitea-actions
b94c3a95be chore: bump version to v0.0.85
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m22s
2026-04-21 13:45:43 +00:00
394c69e84a feat: ajout des 3 derniers WS en lecture du bundle malio ednotif (!47)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
- 3 nouveaux endpoints API Platform pass-through sur /api/bovins/{inventory|returned-dossiers|presumed-exits} consommant BovinApiInterface v0.0.6
- AnimalSummaryMapper (src/Service/) factorisant le mapping DTO EDNOTIF -> ressource
- src/State/ réorganisé par domaine (Bovin/, Reception/, Shipment/, Building/, User/, System/)
- tag OpenAPI "Bovins" pour regrouper les endpoints ednotif dans Swagger
- malio/ednotif-bundle passé à v0.0.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #47
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-21 13:45:37 +00:00
gitea-actions
c2074df562 chore: bump version to v0.0.84
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m37s
2026-04-13 11:46:33 +00:00
29bfeeb4ee [#FER-18] Mise à jour du tableau d'arrivage (!45)
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #45
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-13 11:46:26 +00:00
Matthieu
5ac03e359f chore : bump version to v0.0.83
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:45:34 +02:00
Matthieu
340aa2a3c0 feat : écran bovins, refacto cases, enrichissement bovins, migrations
- Ajout page infrastructure/bovine avec CRUD
- Refacto BuildingCase (suppression Statut, simplification)
- Commande EnrichBovinesCommand pour enrichir les données bovins
- 4 migrations Doctrine
- Mise à jour composables shipment/weighing
- Mise à jour README et CHANGELOG

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:44:53 +02:00
gitea-actions
6eb2ee2578 chore: bump version to v0.0.81
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Failing after 1m32s
2026-03-30 13:47:53 +00:00
34c1d162d8 [#FER-15] Fix droit de suppression réception/expédition utilisateur (!43)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #43
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 13:47:46 +00:00
gitea-actions
bbd05cea3e chore: bump version to v0.0.80
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m19s
2026-03-26 16:51:33 +00:00
7f78454553 [#FER-13] Faire des recherches sur le scanner des bêtes (!42)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [x] Pas de régression
- [x] TU/TI/TF rédigée
- [x] TU/TI/TF OK
- [x] CHANGELOG modifié

Reviewed-on: #42
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-26 16:51:27 +00:00
gitea-actions
696100a622 chore: bump version to v0.0.79
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m16s
2026-03-25 14:53:49 +00:00
97f21ab35c [#FER-12] Ajouter un blocage des utilisateurs (!41)
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #41
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 14:53:43 +00:00
99 changed files with 3942 additions and 983 deletions

125
.idea/workspace.xml generated
View File

@@ -4,11 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : bouton de mise en attente"> <list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" /> <change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" /> <change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/services/auth.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/auth.ts" afterDir="false" /> <change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -40,7 +41,7 @@
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="develop" /> <entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -231,7 +232,7 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true", "RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true", "RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "fix/FER-11-corriger-le-probleme-de-bearer-token", "git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme", "last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"node.js.detected.package.eslint": "true", "node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true", "node.js.detected.package.tslint": "true",
@@ -325,55 +326,7 @@
<workItem from="1773766075191" duration="6202000" /> <workItem from="1773766075191" duration="6202000" />
<workItem from="1773824491213" duration="24805000" /> <workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" /> <workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="13844000" /> <workItem from="1774276665015" duration="33750000" />
</task>
<task id="LOCAL-00031" summary="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil">
<option name="closed" value="true" />
<created>1769098861988</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1769098861988</updated>
</task>
<task id="LOCAL-00032" summary="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster">
<option name="closed" value="true" />
<created>1769100048933</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1769100048933</updated>
</task>
<task id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
<option name="closed" value="true" />
<created>1769177611987</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1769177611987</updated>
</task>
<task id="LOCAL-00034" summary="feat : ajout du bundle Malio ednotif pour l'utilisation des WS">
<option name="closed" value="true" />
<created>1769184861047</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1769184861047</updated>
</task>
<task id="LOCAL-00035" summary="fix : modification de la conf du bundle ednotif">
<option name="closed" value="true" />
<created>1769434793487</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1769434793487</updated>
</task>
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1769435038236</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1769435038236</updated>
</task> </task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)"> <task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -719,7 +672,55 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1774337609427</updated> <updated>1774337609427</updated>
</task> </task>
<option name="localTasksCounter" value="80" /> <task id="LOCAL-00080" summary="fix : problème de bearer token">
<option name="closed" value="true" />
<created>1774448105945</created>
<option name="number" value="00080" />
<option name="presentableId" value="LOCAL-00080" />
<option name="project" value="LOCAL" />
<updated>1774448105945</updated>
</task>
<task id="LOCAL-00081" summary="feat : système de blocage utilisateur">
<option name="closed" value="true" />
<created>1774450388149</created>
<option name="number" value="00081" />
<option name="presentableId" value="LOCAL-00081" />
<option name="project" value="LOCAL" />
<updated>1774450388149</updated>
</task>
<task id="LOCAL-00082" summary="feat : ajout d'un système de scanner bovin">
<option name="closed" value="true" />
<created>1774543296474</created>
<option name="number" value="00082" />
<option name="presentableId" value="LOCAL-00082" />
<option name="project" value="LOCAL" />
<updated>1774543296474</updated>
</task>
<task id="LOCAL-00083" summary="feat : mise à jour du CLAUDE.md">
<option name="closed" value="true" />
<created>1774543626516</created>
<option name="number" value="00083" />
<option name="presentableId" value="LOCAL-00083" />
<option name="project" value="LOCAL" />
<updated>1774543626516</updated>
</task>
<task id="LOCAL-00084" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1774543766582</created>
<option name="number" value="00084" />
<option name="presentableId" value="LOCAL-00084" />
<option name="project" value="LOCAL" />
<updated>1774543766582</updated>
</task>
<task id="LOCAL-00085" summary="feat : la page de scanner est accessible que pour les admins">
<option name="closed" value="true" />
<created>1774543840891</created>
<option name="number" value="00085" />
<option name="presentableId" value="LOCAL-00085" />
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<option name="localTasksCounter" value="86" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -769,13 +770,6 @@
</option> </option>
</component> </component>
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<MESSAGE value="feat : changelog" />
<MESSAGE value="feat : lister les expeditions terminees" />
<MESSAGE value="fix: corrections diverses" />
<MESSAGE value="fix : corrections diverses" />
<MESSAGE value="fix : corrections frontend" />
<MESSAGE value="feat : affichage et modification expédition et modification bouton valider" />
<MESSAGE value="fix : erreur customer adress et bouton valider oublie" />
<MESSAGE value="feat : changelog update" /> <MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" /> <MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" /> <MESSAGE value="feat : modification front de la page admin transporteur" />
@@ -794,7 +788,14 @@
<MESSAGE value="fix : order récéption/expédition + correction style bouton récéption" /> <MESSAGE value="fix : order récéption/expédition + correction style bouton récéption" />
<MESSAGE value="fix : style bon de récéption" /> <MESSAGE value="fix : style bon de récéption" />
<MESSAGE value="fix : bouton de mise en attente" /> <MESSAGE value="fix : bouton de mise en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : bouton de mise en attente" /> <MESSAGE value="fix : problème de bearer token" />
<MESSAGE value="feat : système de blocage utilisateur" />
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
</component> </component>
<component name="XDebuggerManager"> <component name="XDebuggerManager">
<breakpoint-manager> <breakpoint-manager>

View File

@@ -60,6 +60,11 @@ Ajouter dans le fichier .env du frontend
* [#353] modification front admin client * [#353] modification front admin client
* [#353] modification front admin utilisateur * [#353] modification front admin utilisateur
* [#FER-11] Corriger le problème de bearer token * [#FER-11] Corriger le problème de bearer token
* [#FER-12] Ajouter un blocage des utilisateurs
* [#FER-13] Faire des recherches sur le scanner des bêtes
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#FER-17] Ecran d'ajout de bovin
* [#FER-18] Mise à jour du tableau d'arrivage
### Changed ### Changed

View File

@@ -142,6 +142,15 @@ frontend/
- `BuildingCase` a `bovines` (OneToMany). - `BuildingCase` a `bovines` (OneToMany).
- Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`. - Rapport PDF cases : `GET /building_cases/{id}/weights-report` → template Twig, projection depuis `arrivalDate`, gain journalier fixe `1.3 kg/jour`.
### Scanner boucles auriculaires
- Page dédiée `/scan` : scan de codes-barres Code 39/128 (boucles auriculaires bovines) depuis un téléphone Android via Chrome.
- Utilise l'API native `BarcodeDetector` (Shape Detection API, Chrome Android 83+) — pas de lib JS, décodage hardware quasi-instantané.
- **Non supporté sur iOS** (tous les navigateurs iOS utilisent WebKit, qui n'implémente pas `BarcodeDetector`).
- Les 4 premiers caractères du code-barres sont retirés avant enregistrement (`rawValue.slice(4)`).
- Composable `useBarcodeScanner` : caméra arrière, anti-doublon 2s, vibration au scan.
- Le bovin est créé via `POST /bovines` avec `Content-Type: application/ld+json` (nécessaire pour la résolution d'IRI de `buildingCase`).
- Sélection bâtiment → case (filtrées dynamiquement) avant de scanner.
### Données de référence ### Données de référence
- `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table, `createdBy` → User), `Customer` (avec `Address` via join table, `createdBy` → User), `Truck`, `Carrier`, `Driver`, `Vehicle`. - `ReceptionType`, `MerchandiseType`, `PelletType`, `Building`, `Supplier` (avec `Address` via join table, `createdBy` → User), `Customer` (avec `Address` via join table, `createdBy` → User), `Truck`, `Carrier`, `Driver`, `Vehicle`.
- `Address` : champ `label` nullable (déprécié, retiré du front et du `address:write`), expose `fullAddress` via getter. `countryCode` par défaut `FR` côté front. - `Address` : champ `label` nullable (déprécié, retiré du front et du `address:write`), expose `fullAddress` via getter. `countryCode` par défaut `FR` côté front.

View File

@@ -1,68 +1,87 @@
# Projet Ferme # Projet Ferme t
## Installation du projet ## Installation du projet
### Windows ### Windows
Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm. Pour windows, il faut installer le WSL2, Ubuntu, docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows) Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/windows)
### Linux ### Linux
Pour linux, il faut installer docker et nvm. Pour linux, il faut installer docker et nvm.
Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux) Il suffit de suivre cette [doc](https://wiki.malio.fr/bookstack/books/environnement-de-dev/chapter/linux)
### Installation du projet ### Installation du projet
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
```bash ```bash
make start make start
make install make install
``` ```
Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible. Dans le cas ou le `make start` plante à cause du port de la bdd, il faut modifier **POSTGRES_PORT** dans le fichier .env.docker.local, remplacer le par un port disponible.
### Configuration global ### Configuration global
Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue. Pour les variables d'environnement, il faut demander un .env.local pour le backend et un .env pour le frontend à votre collègue.
Vérifier que dans le .env.local, vous avez : Vérifier que dans le .env.local, vous avez :
* APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
* DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" - APP_SECRET (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));" et doit être différent de celui de votre collègue, puisque utilisé pour signer des tokens)
* PONT_BASCULE_BYPASS (doit être à true en dev) - DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8"
* PONT_BASCULE_URL - PONT_BASCULE_BYPASS (doit être à true en dev)
* JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair) - PONT_BASCULE_URL
* JWT_PUBLIC_KEY - JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
* JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));") - JWT_PUBLIC_KEY
* COOKIE_SECURE=0 (en dev 0 et en prod 1. Si c'est du http, laisser en 0) - JWT_PASSPHRASE (à généré dans le conteneur avec la commande php -r "echo bin2hex(random_bytes(32));")
- COOKIE_SECURE=0 (en dev 0 et en prod 1. Si c'est du http, laisser en 0)
Vérifier que dans le .env du dossier frontend, vous avez : Vérifier que dans le .env du dossier frontend, vous avez :
* NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
- NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
### Configuration xdebug ### Configuration xdebug
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br> Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
Pour cela, il faut aller dans **Settings > PHP > Servers** <br> Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
* Name : ferme-docker
* Host : localhost - Name : ferme-docker
* Port : 8080 - Host : localhost
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html - Port : 8080
- Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local Pour que xdebug fonctionne sur windows, il faut modifier la variable **XDEBUG_CLIENT_HOST** par votre ip local
## Utilisation du projet ## Utilisation du projet
### Backend ### Backend
L'api est disponible sur http://localhost:8080/api L'api est disponible sur http://localhost:8080/api
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local** Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
Vous pouvez modifier le port si nécessaire. Vous pouvez modifier le port si nécessaire.
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter. La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
C'est un bdd local dans le docker. C'est un bdd local dans le docker.
### Frontend ### Frontend
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
```bash ```bash
make dev-nuxt make dev-nuxt
``` ```
Le front sera accessible sur http://localhost:3000 Le front sera accessible sur http://localhost:3000
### Authentification ### Authentification
Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle). Ce projet utilise l'authentification JWT avec un cookie httpOnly (LexikJWTAuthenticationBundle).
Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie. Le frontend ne lit jamais directement le token, le navigateur envoie automatiquement le cookie.
### Login flow ### Login flow
- Frontend envoie les identifiants à: - Frontend envoie les identifiants à:
- `POST /api/login_check` - `POST /api/login_check`
- Backend returns: - Backend returns:
@@ -72,63 +91,90 @@ Le frontend ne lit jamais directement le token, le navigateur envoie automatique
- La déconnexion utilise `POST /api/logout` et redirige vers `/login`. - La déconnexion utilise `POST /api/logout` et redirige vers `/login`.
### Fixtures ### Fixtures
Pour lancer les fixtures (Attention sa purge la bdd complètement) Pour lancer les fixtures (Attention sa purge la bdd complètement)
```bash ```bash
php bin/console doctrine:fixtures:load php bin/console doctrine:fixtures:load
``` ```
Attention cette commande est dangereuse, à utiliser que pour les débuts de la prod ou en recette. Attention cette commande est dangereuse, à utiliser que pour les débuts de la prod ou en recette.
Dans un premier temps pour remplir les listes, vous pouvez lancer la commande symfony Dans un premier temps pour remplir les listes, vous pouvez lancer la commande symfony
```bash ```bash
php bin/console app:seed php bin/console app:seed
``` ```
La commande va faire une update ou une création en fonction des data existante. La commande va faire une update ou une création en fonction des data existante.
## Livraison en recette ## Livraison en recette
### Préparatifs ### Préparatifs
Avant de déployer, il faut penser à ajouter les variables d'env s'il y a des changements/modifications. Avant de déployer, il faut penser à ajouter les variables d'env s'il y a des changements/modifications.
Le .env se trouve /var/www/ferme/.env Le .env se trouve /var/www/ferme/.env
Le script de livraison est version dans le repo dans script/deploy-release.sh <br> Le script de livraison est version dans le repo dans script/deploy-release.sh <br>
Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br> Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br>
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
### Livraison ### Livraison
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
```bash
```bash
/usr/local/bin/deploy-ferme vX.Y.Z /usr/local/bin/deploy-ferme vX.Y.Z
``` ```
## Commandes utiles ## Commandes utiles
Pour restart le container Pour restart le container
```bash ```bash
make restart make restart
``` ```
Pour lancer les TU Pour lancer les TU
```bash ```bash
make test make test
``` ```
Pour accéder au container et lance des commandes Pour accéder au container et lance des commandes
```bash ```bash
make shell make shell
``` ```
Pour clear le cache Symfony Pour clear le cache Symfony
```bash ```bash
make cache-clear make cache-clear
``` ```
Faire une migration Faire une migration
```bash ```bash
make migration-migrate make migration-migrate
``` ```
Pour générer un password pour un user Pour générer un password pour un user
```bash ```bash
make shell make shell
php bin/console security:hash-password php bin/console security:hash-password
``` ```
Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant : Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant :
```sql ```sql
INSERT INTO "user" (username, roles, password) INSERT INTO "user" (username, roles, password)
VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé'); VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé');
``` ```
## Gestion des logs ## Gestion des logs
Pour suivre les logs en temps réel : Pour suivre les logs en temps réel :
* tail -f var/log/dev.log
* tail -f var/log/prod.log - tail -f var/log/dev.log
- tail -f var/log/prod.log

View File

@@ -14,7 +14,7 @@
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"dompdf/dompdf": "^3.1", "dompdf/dompdf": "^3.1",
"lexik/jwt-authentication-bundle": "*", "lexik/jwt-authentication-bundle": "*",
"malio/ednotif-bundle": ">=0.0.4", "malio/ednotif-bundle": ">=0.0.6",
"nelmio/cors-bundle": "^2.6", "nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6", "phpdocumentor/reflection-docblock": "^5.6",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",

176
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9c04091eea0e10c19713a1d882b04f91", "content-hash": "fd62fc3833815b11aa058fd2759c1c79",
"packages": [ "packages": [
{ {
"name": "api-platform/doctrine-common", "name": "api-platform/doctrine-common",
@@ -2706,11 +2706,11 @@
}, },
{ {
"name": "malio/ednotif-bundle", "name": "malio/ednotif-bundle",
"version": "v0.0.4", "version": "v0.0.6",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://gitea.malio.fr/MALIO-DEV/ednotif-bundle", "url": "https://gitea.malio.fr/MALIO-DEV/ednotif-bundle",
"reference": "92c058213b34ba61f4aa6c03e11ce1ea8cc71421" "reference": "f757822f366bd5f55588aa89e0ec5a5d0e811f1f"
}, },
"require": { "require": {
"ext-soap": "*", "ext-soap": "*",
@@ -2744,7 +2744,7 @@
"MIT" "MIT"
], ],
"description": "Client EDNOTIF (Guichet + wsIpBNotif) pour Symfony", "description": "Client EDNOTIF (Guichet + wsIpBNotif) pour Symfony",
"time": "2026-01-26T13:24:38+00:00" "time": "2026-04-21T08:14:37+00:00"
}, },
{ {
"name": "masterminds/html5", "name": "masterminds/html5",
@@ -3655,16 +3655,16 @@
}, },
{ {
"name": "symfony/cache", "name": "symfony/cache",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/cache.git", "url": "https://github.com/symfony/cache.git",
"reference": "5d3fcada5e1b80157cfdfd1f9dbbd63f95ef6f13" "reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/cache/zipball/5d3fcada5e1b80157cfdfd1f9dbbd63f95ef6f13", "url": "https://api.github.com/repos/symfony/cache/zipball/8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78",
"reference": "5d3fcada5e1b80157cfdfd1f9dbbd63f95ef6f13", "reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3731,7 +3731,7 @@
"psr6" "psr6"
], ],
"support": { "support": {
"source": "https://github.com/symfony/cache/tree/v8.0.4" "source": "https://github.com/symfony/cache/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -3751,7 +3751,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-23T12:59:31+00:00" "time": "2026-03-30T15:18:51+00:00"
}, },
{ {
"name": "symfony/cache-contracts", "name": "symfony/cache-contracts",
@@ -3908,16 +3908,16 @@
}, },
{ {
"name": "symfony/config", "name": "symfony/config",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/config.git", "url": "https://github.com/symfony/config.git",
"reference": "8f45af92f08f82902827a8b6f403aaf49d893539" "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/config/zipball/8f45af92f08f82902827a8b6f403aaf49d893539", "url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39",
"reference": "8f45af92f08f82902827a8b6f403aaf49d893539", "reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3962,7 +3962,7 @@
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/config/tree/v8.0.4" "source": "https://github.com/symfony/config/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -3982,7 +3982,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-13T13:06:50+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{ {
"name": "symfony/console", "name": "symfony/console",
@@ -4076,16 +4076,16 @@
}, },
{ {
"name": "symfony/dependency-injection", "name": "symfony/dependency-injection",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/dependency-injection.git", "url": "https://github.com/symfony/dependency-injection.git",
"reference": "59c3cf87a7ed9beb561cf7433a6635b000a0ff4d" "reference": "3ce58b0fa844dc647ca1d66ea34748af985728c5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/59c3cf87a7ed9beb561cf7433a6635b000a0ff4d", "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/3ce58b0fa844dc647ca1d66ea34748af985728c5",
"reference": "59c3cf87a7ed9beb561cf7433a6635b000a0ff4d", "reference": "3ce58b0fa844dc647ca1d66ea34748af985728c5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4133,7 +4133,7 @@
"description": "Allows you to standardize and centralize the way objects are constructed in your application", "description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/dependency-injection/tree/v8.0.4" "source": "https://github.com/symfony/dependency-injection/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -4153,7 +4153,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-23T12:59:31+00:00" "time": "2026-03-31T07:15:36+00:00"
}, },
{ {
"name": "symfony/deprecation-contracts", "name": "symfony/deprecation-contracts",
@@ -4400,16 +4400,16 @@
}, },
{ {
"name": "symfony/error-handler", "name": "symfony/error-handler",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/error-handler.git", "url": "https://github.com/symfony/error-handler.git",
"reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a" "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/7620b97ec0ab1d2d6c7fb737aa55da411bea776a", "url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517",
"reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a", "reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4457,7 +4457,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code", "description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/error-handler/tree/v8.0.4" "source": "https://github.com/symfony/error-handler/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -4477,20 +4477,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-23T11:07:10+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{ {
"name": "symfony/event-dispatcher", "name": "symfony/event-dispatcher",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/event-dispatcher.git", "url": "https://github.com/symfony/event-dispatcher.git",
"reference": "99301401da182b6cfaa4700dbe9987bb75474b47" "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6",
"reference": "99301401da182b6cfaa4700dbe9987bb75474b47", "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4542,7 +4542,7 @@
"description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -4562,7 +4562,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-05T11:45:55+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{ {
"name": "symfony/event-dispatcher-contracts", "name": "symfony/event-dispatcher-contracts",
@@ -4709,16 +4709,16 @@
}, },
{ {
"name": "symfony/filesystem", "name": "symfony/filesystem",
"version": "v8.0.1", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/filesystem.git", "url": "https://github.com/symfony/filesystem.git",
"reference": "d937d400b980523dc9ee946bb69972b5e619058d" "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d", "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a",
"reference": "d937d400b980523dc9ee946bb69972b5e619058d", "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4755,7 +4755,7 @@
"description": "Provides basic utilities for the filesystem", "description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/filesystem/tree/v8.0.1" "source": "https://github.com/symfony/filesystem/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -4775,7 +4775,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-12-01T09:13:36+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{ {
"name": "symfony/finder", "name": "symfony/finder",
@@ -5234,16 +5234,16 @@
}, },
{ {
"name": "symfony/http-foundation", "name": "symfony/http-foundation",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-foundation.git", "url": "https://github.com/symfony/http-foundation.git",
"reference": "e33ba71e674a1bb16eb251688bd27c2ff67e0dc1" "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/e33ba71e674a1bb16eb251688bd27c2ff67e0dc1", "url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b",
"reference": "e33ba71e674a1bb16eb251688bd27c2ff67e0dc1", "reference": "02656f7ebeae5c155d659e946f6b3a33df24051b",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5290,7 +5290,7 @@
"description": "Defines an object-oriented layer for the HTTP specification", "description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-foundation/tree/v8.0.4" "source": "https://github.com/symfony/http-foundation/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -5310,20 +5310,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-09T12:15:10+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{ {
"name": "symfony/http-kernel", "name": "symfony/http-kernel",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/http-kernel.git", "url": "https://github.com/symfony/http-kernel.git",
"reference": "3e61425b663a995957217d03c444b9d27ca7d928" "reference": "1770f6818d83b2fddc12185025b93f39a90cb628"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/3e61425b663a995957217d03c444b9d27ca7d928", "url": "https://api.github.com/repos/symfony/http-kernel/zipball/1770f6818d83b2fddc12185025b93f39a90cb628",
"reference": "3e61425b663a995957217d03c444b9d27ca7d928", "reference": "1770f6818d83b2fddc12185025b93f39a90cb628",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5394,7 +5394,7 @@
"description": "Provides a structured process for converting a Request into a Response", "description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/http-kernel/tree/v8.0.4" "source": "https://github.com/symfony/http-kernel/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -5414,7 +5414,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-25T08:21:00+00:00" "time": "2026-03-31T21:14:05+00:00"
}, },
{ {
"name": "symfony/monolog-bridge", "name": "symfony/monolog-bridge",
@@ -5574,16 +5574,16 @@
}, },
{ {
"name": "symfony/options-resolver", "name": "symfony/options-resolver",
"version": "v8.0.0", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/options-resolver.git", "url": "https://github.com/symfony/options-resolver.git",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5621,7 +5621,7 @@
"options" "options"
], ],
"support": { "support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0" "source": "https://github.com/symfony/options-resolver/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -5641,7 +5641,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-11-12T15:55:31+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{ {
"name": "symfony/password-hasher", "name": "symfony/password-hasher",
@@ -5885,16 +5885,16 @@
}, },
{ {
"name": "symfony/polyfill-mbstring", "name": "symfony/polyfill-mbstring",
"version": "v1.33.0", "version": "v1.36.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git", "url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493" "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493", "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493", "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -5946,7 +5946,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0"
}, },
"funding": [ "funding": [
{ {
@@ -5966,20 +5966,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2024-12-23T08:48:59+00:00" "time": "2026-04-10T17:25:58+00:00"
}, },
{ {
"name": "symfony/polyfill-php85", "name": "symfony/polyfill-php85",
"version": "v1.33.0", "version": "v1.36.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/polyfill-php85.git", "url": "https://github.com/symfony/polyfill-php85.git",
"reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" "reference": "2c408a6bb0313e6001a83628dc5506100474254e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e",
"reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", "reference": "2c408a6bb0313e6001a83628dc5506100474254e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -6026,7 +6026,7 @@
"shim" "shim"
], ],
"support": { "support": {
"source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" "source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0"
}, },
"funding": [ "funding": [
{ {
@@ -6046,7 +6046,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-06-23T16:12:55+00:00" "time": "2026-04-10T16:50:15+00:00"
}, },
{ {
"name": "symfony/polyfill-uuid", "name": "symfony/polyfill-uuid",
@@ -7667,16 +7667,16 @@
}, },
{ {
"name": "symfony/var-dumper", "name": "symfony/var-dumper",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/var-dumper.git", "url": "https://github.com/symfony/var-dumper.git",
"reference": "326e0406fc315eca57ef5740fa4a280b7a068c82" "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/326e0406fc315eca57ef5740fa4a280b7a068c82", "url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1",
"reference": "326e0406fc315eca57ef5740fa4a280b7a068c82", "reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7730,7 +7730,7 @@
"dump" "dump"
], ],
"support": { "support": {
"source": "https://github.com/symfony/var-dumper/tree/v8.0.4" "source": "https://github.com/symfony/var-dumper/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -7750,20 +7750,20 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-01T23:07:29+00:00" "time": "2026-03-31T07:15:36+00:00"
}, },
{ {
"name": "symfony/var-exporter", "name": "symfony/var-exporter",
"version": "v8.0.0", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/var-exporter.git", "url": "https://github.com/symfony/var-exporter.git",
"reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" "reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", "url": "https://api.github.com/repos/symfony/var-exporter/zipball/15776bb07a91b089037da89f8832fa41d5fa6ec6",
"reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", "reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -7810,7 +7810,7 @@
"serialize" "serialize"
], ],
"support": { "support": {
"source": "https://github.com/symfony/var-exporter/tree/v8.0.0" "source": "https://github.com/symfony/var-exporter/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -7830,7 +7830,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2025-11-05T18:53:00+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{ {
"name": "symfony/web-link", "name": "symfony/web-link",
@@ -11457,16 +11457,16 @@
}, },
{ {
"name": "symfony/process", "name": "symfony/process",
"version": "v8.0.4", "version": "v8.0.8",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/symfony/process.git", "url": "https://github.com/symfony/process.git",
"reference": "10df72602d88c0a3fa685b822976a052611dd607" "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/10df72602d88c0a3fa685b822976a052611dd607", "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"reference": "10df72602d88c0a3fa685b822976a052611dd607", "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11498,7 +11498,7 @@
"description": "Executes commands in sub-processes", "description": "Executes commands in sub-processes",
"homepage": "https://symfony.com", "homepage": "https://symfony.com",
"support": { "support": {
"source": "https://github.com/symfony/process/tree/v8.0.4" "source": "https://github.com/symfony/process/tree/v8.0.8"
}, },
"funding": [ "funding": [
{ {
@@ -11518,7 +11518,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-01-23T11:07:10+00:00" "time": "2026-03-30T15:14:47+00:00"
}, },
{ {
"name": "symfony/web-profiler-bundle", "name": "symfony/web-profiler-bundle",

View File

@@ -5,6 +5,8 @@ api_platform:
stateless: true stateless: true
cache_headers: cache_headers:
vary: ['Content-Type', 'Authorization', 'Origin'] vary: ['Content-Type', 'Authorization', 'Origin']
pagination_client_items_per_page: true
pagination_maximum_items_per_page: 100
formats: formats:
json: ['application/json'] json: ['application/json']
jsonld: ['application/ld+json'] jsonld: ['application/ld+json']

View File

@@ -20,6 +20,7 @@ security:
pattern: ^/login_check pattern: ^/login_check
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Security\UserChecker
json_login: json_login:
check_path: /login_check check_path: /login_check
username_path: username username_path: username
@@ -30,6 +31,7 @@ security:
pattern: ^/ pattern: ^/
stateless: true stateless: true
provider: app_user_provider provider: app_user_provider
user_checker: App\Security\UserChecker
jwt: ~ jwt: ~
logout: logout:
path: /api/logout path: /api/logout

View File

@@ -210,18 +210,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* initial_marking?: list<scalar|Param|null>, * initial_marking?: list<scalar|Param|null>,
* events_to_dispatch?: list<string|Param>|null, * events_to_dispatch?: list<string|Param>|null,
* places?: list<array{ // Default: [] * places?: list<array{ // Default: []
* name: scalar|Param|null, * name?: scalar|Param|null,
* metadata?: list<mixed>, * metadata?: list<mixed>,
* }>, * }>,
* transitions: list<array{ // Default: [] * transitions?: list<array{ // Default: []
* name: string|Param, * name?: string|Param,
* guard?: string|Param, // An expression to block the transition. * guard?: string|Param, // An expression to block the transition.
* from?: list<array{ // Default: [] * from?: list<array{ // Default: []
* place: string|Param, * place?: string|Param,
* weight?: int|Param, // Default: 1 * weight?: int|Param, // Default: 1
* }>, * }>,
* to?: list<array{ // Default: [] * to?: list<array{ // Default: []
* place: string|Param, * place?: string|Param,
* weight?: int|Param, // Default: 1 * weight?: int|Param, // Default: 1
* }>, * }>,
* weight?: int|Param, // Default: 1 * weight?: int|Param, // Default: 1
@@ -232,7 +232,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* router?: bool|array{ // Router configuration * router?: bool|array{ // Router configuration
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* resource: scalar|Param|null, * resource?: scalar|Param|null,
* type?: scalar|Param|null, * type?: scalar|Param|null,
* default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null
* http_port?: scalar|Param|null, // Default: 80 * http_port?: scalar|Param|null, // Default: 80
@@ -457,7 +457,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* allow_no_senders?: bool|Param, // Default: true * allow_no_senders?: bool|Param, // Default: true
* }, * },
* middleware?: list<string|array{ // Default: [] * middleware?: list<string|array{ // Default: []
* id: scalar|Param|null, * id?: scalar|Param|null,
* arguments?: list<mixed>, * arguments?: list<mixed>,
* }>, * }>,
* }>, * }>,
@@ -629,7 +629,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto" * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by this limiter (or null to disable locking). // Default: "auto"
* cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter" * cache_pool?: scalar|Param|null, // The cache pool to use for storing the current limiter state. // Default: "cache.rate_limiter"
* storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null * storage_service?: scalar|Param|null, // The service ID of a custom storage implementation, this precedes any configured "cache_pool". // Default: null
* policy: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter. * policy?: "fixed_window"|"token_bucket"|"sliding_window"|"compound"|"no_limit"|Param, // The algorithm to be used by this limiter.
* limiters?: list<scalar|Param|null>, * limiters?: list<scalar|Param|null>,
* limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst.
* interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent).
@@ -674,7 +674,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
* routing?: array<string, array{ // Default: [] * routing?: array<string, array{ // Default: []
* service: scalar|Param|null, * service?: scalar|Param|null,
* secret?: scalar|Param|null, // Default: "" * secret?: scalar|Param|null, // Default: ""
* }>, * }>,
* }, * },
@@ -754,8 +754,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>, * }>,
* }, * },
* ldap?: array{ * ldap?: array{
* service: scalar|Param|null, * service?: scalar|Param|null,
* base_dn: scalar|Param|null, * base_dn?: scalar|Param|null,
* search_dn?: scalar|Param|null, // Default: null * search_dn?: scalar|Param|null, // Default: null
* search_password?: scalar|Param|null, // Default: null * search_password?: scalar|Param|null, // Default: null
* extra_fields?: list<scalar|Param|null>, * extra_fields?: list<scalar|Param|null>,
@@ -766,7 +766,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* password_attribute?: scalar|Param|null, // Default: null * password_attribute?: scalar|Param|null, // Default: null
* }, * },
* entity?: array{ * entity?: array{
* class: scalar|Param|null, // The full entity class name of your user class. * class?: scalar|Param|null, // The full entity class name of your user class.
* property?: scalar|Param|null, // Default: null * property?: scalar|Param|null, // Default: null
* manager_name?: scalar|Param|null, // Default: null * manager_name?: scalar|Param|null, // Default: null
* }, * },
@@ -774,7 +774,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser" * class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser"
* }, * },
* }>, * }>,
* firewalls: array<string, array{ // Default: [] * firewalls?: array<string, array{ // Default: []
* pattern?: scalar|Param|null, * pattern?: scalar|Param|null,
* host?: scalar|Param|null, * host?: scalar|Param|null,
* methods?: list<scalar|Param|null>, * methods?: list<scalar|Param|null>,
@@ -836,9 +836,9 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator" * authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator"
* }, * },
* login_link?: array{ * login_link?: array{
* check_route: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". * check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify".
* check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false
* signature_properties: list<scalar|Param|null>, * signature_properties?: list<scalar|Param|null>,
* lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600
* max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null
* used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set.
@@ -940,13 +940,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* failure_handler?: scalar|Param|null, * failure_handler?: scalar|Param|null,
* realm?: scalar|Param|null, // Default: null * realm?: scalar|Param|null, // Default: null
* token_extractors?: list<scalar|Param|null>, * token_extractors?: list<scalar|Param|null>,
* token_handler: string|array{ * token_handler?: string|array{
* id?: scalar|Param|null, * id?: scalar|Param|null,
* oidc_user_info?: string|array{ * oidc_user_info?: string|array{
* base_uri: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured).
* discovery?: array{ // Enable the OIDC discovery. * discovery?: array{ // Enable the OIDC discovery.
* cache?: array{ * cache?: array{
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
* }, * },
* }, * },
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub"
@@ -954,25 +954,25 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* oidc?: array{ * oidc?: array{
* discovery?: array{ // Enable the OIDC discovery. * discovery?: array{ // Enable the OIDC discovery.
* base_uri: list<scalar|Param|null>, * base_uri?: list<scalar|Param|null>,
* cache?: array{ * cache?: array{
* id: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration.
* }, * },
* }, * },
* claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub"
* audience: scalar|Param|null, // Audience set in the token, for validation purpose. * audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
* issuers: list<scalar|Param|null>, * issuers?: list<scalar|Param|null>,
* algorithms: list<scalar|Param|null>, * algorithms?: list<scalar|Param|null>,
* keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).
* encryption?: bool|array{ * encryption?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
* algorithms: list<scalar|Param|null>, * algorithms?: list<scalar|Param|null>,
* keyset: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys).
* }, * },
* }, * },
* cas?: array{ * cas?: array{
* validation_url: scalar|Param|null, // CAS server validation URL * validation_url?: scalar|Param|null, // CAS server validation URL
* prefix?: scalar|Param|null, // CAS prefix // Default: "cas" * prefix?: scalar|Param|null, // CAS prefix // Default: "cas"
* http_client?: scalar|Param|null, // HTTP Client service // Default: null * http_client?: scalar|Param|null, // HTTP Client service // Default: null
* }, * },
@@ -1036,7 +1036,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* dbal?: array{ * dbal?: array{
* default_connection?: scalar|Param|null, * default_connection?: scalar|Param|null,
* types?: array<string, string|array{ // Default: [] * types?: array<string, string|array{ // Default: []
* class: scalar|Param|null, * class?: scalar|Param|null,
* }>, * }>,
* driver_schemes?: array<string, scalar|Param|null>, * driver_schemes?: array<string, scalar|Param|null>,
* connections?: array<string, array{ // Default: [] * connections?: array<string, array{ // Default: []
@@ -1207,7 +1207,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* datetime_functions?: array<string, scalar|Param|null>, * datetime_functions?: array<string, scalar|Param|null>,
* }, * },
* filters?: array<string, string|array{ // Default: [] * filters?: array<string, string|array{ // Default: []
* class: scalar|Param|null, * class?: scalar|Param|null,
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* parameters?: array<string, mixed>, * parameters?: array<string, mixed>,
* }>, * }>,
@@ -1320,14 +1320,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* access_token_issuance?: bool|array{ * access_token_issuance?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* signature?: array{ * signature?: array{
* algorithm: scalar|Param|null, // The algorithm use to sign the access tokens. * algorithm?: scalar|Param|null, // The algorithm use to sign the access tokens.
* key: scalar|Param|null, // The signature key. It shall be JWK encoded. * key?: scalar|Param|null, // The signature key. It shall be JWK encoded.
* }, * },
* encryption?: bool|array{ * encryption?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
* key_encryption_algorithm: scalar|Param|null, // The key encryption algorithm is used to encrypt the token. * key_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
* content_encryption_algorithm: scalar|Param|null, // The key encryption algorithm is used to encrypt the token. * content_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token.
* key: scalar|Param|null, // The encryption key. It shall be JWK encoded. * key?: scalar|Param|null, // The encryption key. It shall be JWK encoded.
* }, * },
* }, * },
* access_token_verification?: bool|array{ * access_token_verification?: bool|array{
@@ -1337,7 +1337,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* claim_checkers?: list<scalar|Param|null>, * claim_checkers?: list<scalar|Param|null>,
* mandatory_claims?: list<scalar|Param|null>, * mandatory_claims?: list<scalar|Param|null>,
* allowed_algorithms?: list<scalar|Param|null>, * allowed_algorithms?: list<scalar|Param|null>,
* keyset: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded. * keyset?: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded.
* }, * },
* encryption?: bool|array{ * encryption?: bool|array{
* enabled?: bool|Param, // Default: false * enabled?: bool|Param, // Default: false
@@ -1345,7 +1345,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* header_checkers?: list<scalar|Param|null>, * header_checkers?: list<scalar|Param|null>,
* allowed_key_encryption_algorithms?: list<scalar|Param|null>, * allowed_key_encryption_algorithms?: list<scalar|Param|null>,
* allowed_content_encryption_algorithms?: list<scalar|Param|null>, * allowed_content_encryption_algorithms?: list<scalar|Param|null>,
* keyset: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded. * keyset?: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded.
* }, * },
* }, * },
* blocklist_token?: bool|array{ * blocklist_token?: bool|array{
@@ -1489,7 +1489,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }, * },
* termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null * termsOfService?: scalar|Param|null, // A URL to the Terms of Service for the API. MUST be in the format of a URL. // Default: null
* tags?: list<array{ // Default: [] * tags?: list<array{ // Default: []
* name: scalar|Param|null, * name?: scalar|Param|null,
* description?: scalar|Param|null, // Default: null * description?: scalar|Param|null, // Default: null
* }>, * }>,
* license?: array{ * license?: array{
@@ -1605,14 +1605,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* name?: mixed, * name?: mixed,
* allow_create?: mixed, * allow_create?: mixed,
* item_uri_template?: mixed, * item_uri_template?: mixed,
* ...<mixed> * ...<string, mixed>
* }, * },
* } * }
* @psalm-type MonologConfig = array{ * @psalm-type MonologConfig = array{
* use_microseconds?: scalar|Param|null, // Default: true * use_microseconds?: scalar|Param|null, // Default: true
* channels?: list<scalar|Param|null>, * channels?: list<scalar|Param|null>,
* handlers?: array<string, array{ // Default: [] * handlers?: array<string, array{ // Default: []
* type: scalar|Param|null, * type?: scalar|Param|null,
* id?: scalar|Param|null, * id?: scalar|Param|null,
* enabled?: bool|Param, // Default: true * enabled?: bool|Param, // Default: true
* priority?: scalar|Param|null, // Default: 0 * priority?: scalar|Param|null, // Default: 0
@@ -1735,7 +1735,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* headers?: list<scalar|Param|null>, * headers?: list<scalar|Param|null>,
* mailer?: scalar|Param|null, // Default: null * mailer?: scalar|Param|null, // Default: null
* email_prototype?: string|array{ * email_prototype?: string|array{
* id: scalar|Param|null, * id?: scalar|Param|null,
* method?: scalar|Param|null, // Default: null * method?: scalar|Param|null, // Default: null
* }, * },
* verbosity_levels?: array{ * verbosity_levels?: array{
@@ -1752,14 +1752,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>, * }>,
* } * }
* @psalm-type EdnotifConfig = array{ * @psalm-type EdnotifConfig = array{
* guichet_wsdl: scalar|Param|null, * guichet_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/WsGuichet.wsdl"
* metier_wsdl: scalar|Param|null, * metier_wsdl?: scalar|Param|null, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/wsIpBNotif.wsdl"
* exploitation_code: scalar|Param|null, * exploitation_code?: scalar|Param|null,
* zone?: scalar|Param|null, // Default: null * zone?: scalar|Param|null, // Default: null
* application?: scalar|Param|null, // Default: null * application?: scalar|Param|null, // Default: null
* login: scalar|Param|null, * login?: scalar|Param|null,
* password: scalar|Param|null, * password?: scalar|Param|null,
* exploitation_number: scalar|Param|null, * exploitation_number?: scalar|Param|null,
* exploitation_country_code?: scalar|Param|null, // Default: "FR" * exploitation_country_code?: scalar|Param|null, // Default: "FR"
* token_ttl_seconds?: int|Param, // Default: 900 * token_ttl_seconds?: int|Param, // Default: 900
* soap_options?: array{ * soap_options?: array{

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.78' app.version: '0.0.88'

View File

@@ -0,0 +1,238 @@
<template>
<div class="w-full">
<div class="relative border border-slate-200">
<div
class="grid items-center gap-6 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
:style="{ gridTemplateColumns: gridCols }"
>
<div v-for="col in columns" :key="col.key" class="min-w-0">
<slot :name="`header-${col.key}`" :column="col">{{ col.label }}</slot>
</div>
<div v-if="showActions" class="min-w-0">
<slot name="header-actions">Actions</slot>
</div>
</div>
<div :class="dimRows ? 'opacity-50 transition-opacity' : ''" :aria-busy="loading || undefined">
<template v-if="paginatedItems.length">
<div
v-for="(item, index) in paginatedItems"
:key="item.id ?? index"
class="grid gap-6 px-4 py-3 text-sm border-t border-slate-200"
:class="[
rowClickable ? 'hover:bg-slate-50 cursor-pointer' : '',
rowClass ? rowClass(item) : ''
]"
:style="{ gridTemplateColumns: gridCols }"
:role="rowClickable ? 'button' : undefined"
:tabindex="rowClickable ? 0 : undefined"
@click="onRowClick(item)"
@keydown.enter="onRowClick(item)"
@keydown.space.prevent="onRowClick(item)"
>
<div v-for="col in columns" :key="col.key" class="min-w-0 truncate">
<slot :name="`cell-${col.key}`" :item="item" :column="col">
{{ getNestedValue(item, col.key) }}
</slot>
</div>
<div v-if="showActions" @click.stop>
<slot name="actions" :item="item" />
</div>
</div>
</template>
<div
v-else-if="loading"
class="flex items-center justify-center border-t border-slate-200 px-4 py-8 text-primary-500"
role="status"
aria-live="polite"
>
<UiLoadingDots />
<span class="sr-only">Chargement</span>
</div>
<div
v-else
class="border-t border-slate-200 px-4 py-8 text-center text-sm text-slate-500"
>
<slot name="empty">{{ emptyMessage }}</slot>
</div>
</div>
<div
v-if="dimRows"
class="pointer-events-none absolute inset-0 flex items-center justify-center"
role="status"
aria-live="polite"
>
<div class="rounded bg-white/80 px-4 py-2 text-primary-500 shadow">
<UiLoadingDots />
<span class="sr-only">Chargement</span>
</div>
</div>
</div>
<div v-if="total > 0" class="flex justify-between pt-2">
<div class="flex items-center gap-3">
<label :for="perPageId" class="whitespace-nowrap text-sm text-slate-700">Lignes&nbsp;:</label>
<select
:id="perPageId"
:value="currentPerPage"
class="h-10 rounded border border-slate-300 bg-white px-2 text-sm text-primary-700"
@change="onPerPageChange(($event.target as HTMLSelectElement).value)"
>
<option v-for="n in perPageOptions" :key="n" :value="n">{{ n }}</option>
</select>
</div>
<nav aria-label="Pagination" class="flex items-center gap-1">
<button
type="button"
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
:disabled="currentPage <= 1"
aria-label="Page précédente"
@click="goToPage(currentPage - 1)"
>
Précédent
</button>
<template v-for="(entry, i) in visiblePages" :key="`${typeof entry}-${entry}-${i}`">
<span
v-if="entry === '...'"
class="px-1 text-sm text-slate-400"
aria-hidden="true"
></span>
<button
v-else
type="button"
class="h-10 min-w-[2.5rem] rounded px-2 text-sm transition-colors"
:class="entry === currentPage
? 'bg-primary-500 font-semibold text-white'
: 'text-slate-700 hover:bg-slate-100'"
:aria-current="entry === currentPage ? 'page' : undefined"
@click="goToPage(entry)"
>
{{ entry }}
</button>
</template>
<button
type="button"
class="h-10 rounded border border-primary-500 bg-white px-3 text-sm text-primary-500 hover:bg-primary-500 hover:text-white disabled:cursor-not-allowed disabled:border-slate-300 disabled:text-slate-400 disabled:hover:bg-white disabled:hover:text-slate-400"
:disabled="currentPage >= totalPages"
aria-label="Page suivante"
@click="goToPage(currentPage + 1)"
>
Suivant
</button>
</nav>
</div>
</div>
</template>
<script setup lang="ts" generic="T extends Record<string, any>">
import { computed, useId } from 'vue'
interface Column {
key: string
label: string
width?: string
}
const props = withDefaults(defineProps<{
columns: Column[]
items: T[]
totalItems?: number
page?: number
perPage?: number
perPageOptions?: number[]
rowClickable?: boolean
showActions?: boolean
emptyMessage?: string
loading?: boolean
rowClass?: (item: T) => string | undefined
}>(), {
totalItems: undefined,
page: 1,
perPage: 10,
perPageOptions: () => [10, 25, 50],
rowClickable: false,
showActions: false,
emptyMessage: 'Aucune donnée',
loading: false,
rowClass: undefined
})
const emit = defineEmits<{
(e: 'update:page', value: number): void
(e: 'update:perPage', value: number): void
(e: 'row-click', item: T): void
}>()
const perPageId = useId()
const currentPage = computed(() => props.page)
const currentPerPage = computed(() => props.perPage)
const isServerSide = computed(() => props.totalItems !== undefined)
const total = computed(() => props.totalItems ?? props.items.length)
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / currentPerPage.value))
)
const paginatedItems = computed(() => {
if (isServerSide.value) return props.items
const start = (currentPage.value - 1) * currentPerPage.value
return props.items.slice(start, start + currentPerPage.value)
})
const gridCols = computed(() => {
const dataCols = props.columns.map(c => c.width ?? '1fr').join(' ')
return props.showActions ? `${dataCols} 60px` : dataCols
})
const dimRows = computed(() => props.loading && paginatedItems.value.length > 0)
const visiblePages = computed<(number | '...')[]>(() => {
const tp = totalPages.value
const cp = currentPage.value
if (tp <= 5) {
return Array.from({ length: tp }, (_, i) => i + 1)
}
const pages: (number | '...')[] = []
pages.push(1)
if (cp > 3) pages.push('...')
const start = Math.max(2, cp - 1)
const end = Math.min(tp - 1, cp + 1)
for (let i = start; i <= end; i++) pages.push(i)
if (cp < tp - 2) pages.push('...')
if (tp > 1) pages.push(tp)
return pages
})
const goToPage = (n: number) => {
if (n < 1 || n > totalPages.value || n === currentPage.value) return
emit('update:page', n)
}
const onPerPageChange = (value: string) => {
emit('update:perPage', Number(value))
emit('update:page', 1)
}
const onRowClick = (item: T) => {
if (!props.rowClickable) return
emit('row-click', item)
}
const getNestedValue = (obj: any, path: string): string => {
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
return value ?? '—'
}
</script>

View File

@@ -14,8 +14,9 @@
:value="modelValue ?? ''" :value="modelValue ?? ''"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" v-bind="attrs"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]" class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent appearance-none"
:class="[ :class="[
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700', isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-pointer', disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass inputClass
@@ -36,12 +37,14 @@ const props = withDefaults(
label?: string label?: string
modelValue: string | null | undefined modelValue: string | null | undefined
disabled?: boolean disabled?: boolean
size?: 'default' | 'compact'
wrapperClass?: string wrapperClass?: string
labelClass?: string labelClass?: string
inputClass?: string inputClass?: string
}>(), }>(),
{ {
disabled: false, disabled: false,
size: 'default',
wrapperClass: '', wrapperClass: '',
labelClass: '', labelClass: '',
inputClass: '' inputClass: ''
@@ -54,6 +57,11 @@ const emit = defineEmits<{
const attrs = useAttrs() const attrs = useAttrs()
const isEmpty = computed(() => !props.modelValue) const isEmpty = computed(() => !props.modelValue)
const sizeClass = computed(() =>
props.size === 'compact'
? 'text-sm h-8 font-normal normal-case tracking-normal'
: 'text-xl py-[6px] uppercase h-[34px]'
)
const onInput = (event: Event) => { const onInput = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement

View File

@@ -0,0 +1,108 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
</label>
<input
:id="id"
v-maska="'##/##/####'"
type="text"
inputmode="numeric"
:value="displayValue"
:placeholder="placeholder"
:disabled="disabled"
v-bind="attrs"
class="w-full min-w-0 border-b border-primary-700 bg-transparent"
:class="[
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass
]"
@input="onInput"
/>
</div>
</template>
<script setup lang="ts">
import { vMaska } from 'maska/vue'
import { computed, ref, useAttrs, watch } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
label?: string
modelValue: string | null | undefined
placeholder?: string
disabled?: boolean
size?: 'default' | 'compact'
wrapperClass?: string
labelClass?: string
inputClass?: string
}>(),
{
placeholder: 'JJ/MM/AAAA',
disabled: false,
size: 'default',
wrapperClass: '',
labelClass: '',
inputClass: ''
}
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const attrs = useAttrs()
const toDisplay = (iso: string | null | undefined): string => {
if (!iso) return ''
const parts = iso.split('-')
if (parts.length !== 3) return ''
const [year, month, day] = parts
if (year.length !== 4 || month.length !== 2 || day.length !== 2) return ''
return `${day}/${month}/${year}`
}
const toIso = (display: string): string | null => {
const match = display.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
if (!match) return null
const [, day, month, year] = match
return `${year}-${month}-${day}`
}
const displayValue = ref(toDisplay(props.modelValue))
watch(() => props.modelValue, (newIso) => {
const expected = toDisplay(newIso)
if (expected !== displayValue.value) {
displayValue.value = expected
}
})
const isEmpty = computed(() => !displayValue.value)
const sizeClass = computed(() =>
props.size === 'compact'
? 'text-sm h-8 font-normal normal-case tracking-normal'
: 'text-xl py-[6px]'
)
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
displayValue.value = target.value
if (target.value === '') {
emit('update:modelValue', '')
return
}
const iso = toIso(target.value)
emit('update:modelValue', iso ?? '')
}
</script>

View File

@@ -13,15 +13,16 @@
:value="modelValue ?? ''" :value="modelValue ?? ''"
:disabled="disabled || loading" :disabled="disabled || loading"
v-bind="attrs" v-bind="attrs"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] bg-transparent" class="w-full min-w-0 border-b border-primary-700 justify-self-start text-primary-700 bg-transparent"
:class="[ :class="[
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700', isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer', disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
selectClass selectClass
]" ]"
@change="onChange" @change="onChange"
> >
<option value="" disabled class="text-neutral-400"> <option value="" class="text-neutral-400">
{{ placeholderText }} {{ placeholderText }}
</option> </option>
<option <option
@@ -55,6 +56,7 @@ const props = withDefaults(
options: SelectOption[] options: SelectOption[]
disabled?: boolean disabled?: boolean
loading?: boolean loading?: boolean
size?: 'default' | 'compact'
wrapperClass?: string wrapperClass?: string
labelClass?: string labelClass?: string
selectClass?: string selectClass?: string
@@ -63,6 +65,7 @@ const props = withDefaults(
placeholder: 'Sélectionner', placeholder: 'Sélectionner',
disabled: false, disabled: false,
loading: false, loading: false,
size: 'default',
wrapperClass: '', wrapperClass: '',
labelClass: '', labelClass: '',
selectClass: '' selectClass: ''
@@ -77,6 +80,11 @@ const attrs = useAttrs()
const isEmpty = computed(() => props.modelValue === '' || props.modelValue === null || props.modelValue === undefined) const isEmpty = computed(() => props.modelValue === '' || props.modelValue === null || props.modelValue === undefined)
const placeholderText = computed(() => props.placeholder || 'Sélectionner') const placeholderText = computed(() => props.placeholder || 'Sélectionner')
const sizeClass = computed(() =>
props.size === 'compact'
? 'text-sm h-8 font-normal normal-case tracking-normal'
: 'text-xl py-[6px]'
)
const onChange = (event: Event) => { const onChange = (event: Event) => {
const target = event.target as HTMLSelectElement const target = event.target as HTMLSelectElement

View File

@@ -16,9 +16,10 @@
:maxlength="maxlength" :maxlength="maxlength"
:disabled="disabled" :disabled="disabled"
v-bind="attrs" v-bind="attrs"
class="border-b border-black text-xl py-[6px] bg-transparent text-primary-700" class="w-full min-w-0 border-b border-primary-700 bg-transparent"
:class="[ :class="[
isEmpty ? 'text-neutral-400' : 'text-black', sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-text', disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass inputClass
]" ]"
@@ -40,6 +41,7 @@ const props = withDefaults(
placeholder?: string placeholder?: string
maxlength?: number | string maxlength?: number | string
disabled?: boolean disabled?: boolean
size?: 'default' | 'compact'
wrapperClass?: string wrapperClass?: string
labelClass?: string labelClass?: string
inputClass?: string inputClass?: string
@@ -48,6 +50,7 @@ const props = withDefaults(
placeholder: '', placeholder: '',
maxlength: undefined, maxlength: undefined,
disabled: false, disabled: false,
size: 'default',
wrapperClass: '', wrapperClass: '',
labelClass: '', labelClass: '',
inputClass: '' inputClass: ''
@@ -60,6 +63,11 @@ const emit = defineEmits<{
const attrs = useAttrs() const attrs = useAttrs()
const isEmpty = computed(() => !props.modelValue) const isEmpty = computed(() => !props.modelValue)
const sizeClass = computed(() =>
props.size === 'compact'
? 'text-sm h-8 font-normal normal-case tracking-normal'
: 'text-xl py-[6px]'
)
const onInput = (event: Event) => { const onInput = (event: Event) => {
const target = event.target as HTMLInputElement const target = event.target as HTMLInputElement

View File

@@ -10,7 +10,7 @@
:maxlength="maxLength" :maxlength="maxLength"
:placeholder="placeholderText" :placeholder="placeholderText"
:required="required" :required="required"
class="border-b border-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]" class="border-b border-primary-700 flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
@input="handleInput" @input="handleInput"
/> />
<UiCheckbox <UiCheckbox

View File

@@ -37,6 +37,7 @@ export const useWeighingStep = (options: UseWeighingStepOptions) => {
entityName: options.entityName, entityName: options.entityName,
apiResource: options.apiResource, apiResource: options.apiResource,
titleLabel: options.titleLabel, titleLabel: options.titleLabel,
isFinal: options.isFinal,
getWeightFromScale: options.getWeightFromScale, getWeightFromScale: options.getWeightFromScale,
updateEntity: options.updateEntity, updateEntity: options.updateEntity,
loadEntity: options.loadEntity loadEntity: options.loadEntity

View File

@@ -0,0 +1,113 @@
import { ref, onUnmounted } from 'vue'
declare global {
interface Window {
BarcodeDetector: new (options?: { formats: string[] }) => {
detect(source: HTMLVideoElement | ImageBitmapSource): Promise<{ rawValue: string }[]>
}
}
const BarcodeDetector: Window['BarcodeDetector'] | undefined
}
export function useBarcodeScanner(onDetected: (code: string) => void) {
const isSupported = ref('BarcodeDetector' in globalThis)
const isScanning = ref(false)
const error = ref<string | null>(null)
let detector: InstanceType<Window['BarcodeDetector']> | null = null
let stream: MediaStream | null = null
let animationFrameId: number | null = null
let lastDetectedCode = ''
let lastDetectedTime = 0
const COOLDOWN_MS = 2000
async function start(videoElement: HTMLVideoElement) {
if (!isSupported.value) {
error.value = 'BarcodeDetector non supporté. Utilisez Chrome sur Android.'
return
}
try {
detector = new BarcodeDetector({ formats: ['code_39', 'code_128'] })
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
})
videoElement.srcObject = stream
await videoElement.play()
isScanning.value = true
error.value = null
scanLoop(videoElement)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur lors du démarrage de la caméra'
isScanning.value = false
}
}
function scanLoop(videoElement: HTMLVideoElement) {
if (!isScanning.value || !detector) return
animationFrameId = requestAnimationFrame(async () => {
try {
if (videoElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
const barcodes = await detector!.detect(videoElement)
if (barcodes.length > 0) {
const code = barcodes[0].rawValue.slice(4)
const now = Date.now()
if (code !== lastDetectedCode || now - lastDetectedTime > COOLDOWN_MS) {
lastDetectedCode = code
lastDetectedTime = now
if (navigator.vibrate) {
navigator.vibrate(100)
}
onDetected(code)
}
}
}
} catch {
// Detection error on single frame, continue
}
scanLoop(videoElement)
})
}
function stop() {
isScanning.value = false
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (stream) {
stream.getTracks().forEach(track => track.stop())
stream = null
}
detector = null
}
onUnmounted(() => {
stop()
})
return {
isSupported,
isScanning,
error,
start,
stop
}
}

View File

@@ -0,0 +1,102 @@
import { ref, watch } from 'vue'
import { useApi } from '~/composables/useApi'
type FilterValue = string | number | boolean | null
export interface UseDataTableServerStateOptions {
initialPerPage?: number
debounceMs?: number
}
export function useDataTableServerState<T = Record<string, unknown>>(
endpoint: string,
initialFilters: Record<string, FilterValue> = {},
options: UseDataTableServerStateOptions = {}
) {
const api = useApi()
const debounceMs = options.debounceMs ?? 300
const initialPerPage = options.initialPerPage ?? 10
const items = ref<T[]>([]) as { value: T[] }
const totalItems = ref(0)
const page = ref(1)
const perPage = ref(initialPerPage)
const filters = ref<Record<string, FilterValue>>({ ...initialFilters })
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
let requestToken = 0
const buildQueryParams = (): Record<string, string | number | boolean> => {
const params: Record<string, string | number | boolean> = {
page: page.value,
itemsPerPage: perPage.value
}
for (const [key, value] of Object.entries(filters.value)) {
if (value === '' || value === null) continue
params[key] = value as string | number | boolean
}
return params
}
const fetchItems = async (): Promise<void> => {
const currentToken = ++requestToken
loading.value = true
try {
const data = await api.get<{ member: T[]; totalItems: number }>(
endpoint,
buildQueryParams(),
{
toast: false,
headers: { Accept: 'application/ld+json' }
}
)
if (currentToken !== requestToken) return
items.value = data?.member ?? []
totalItems.value = data?.totalItems ?? 0
} finally {
if (currentToken === requestToken) {
loading.value = false
}
}
}
const reload = (): void => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
void fetchItems()
}
const scheduleReload = (): void => {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
debounceTimer = null
void fetchItems()
}, debounceMs)
}
watch([page, perPage], () => {
reload()
})
watch(filters, () => {
if (page.value !== 1) {
page.value = 1
return
}
scheduleReload()
}, { deep: true })
return {
items,
totalItems,
page,
perPage,
filters,
loading,
reload
}
}

View File

@@ -12,6 +12,7 @@ export interface UseWeighingOptions {
entityName: 'reception' | 'shipment' entityName: 'reception' | 'shipment'
apiResource: string apiResource: string
titleLabel: string titleLabel: string
isFinal?: boolean
getWeightFromScale: () => Promise<WeightData> getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any> updateEntity: (id: number, payload: any) => Promise<any>
loadEntity?: (id: number) => Promise<any> loadEntity?: (id: number) => Promise<any>
@@ -23,6 +24,7 @@ export const useWeighing = ({
entityName, entityName,
apiResource, apiResource,
titleLabel, titleLabel,
isFinal = false,
getWeightFromScale, getWeightFromScale,
updateEntity, updateEntity,
loadEntity loadEntity
@@ -77,7 +79,7 @@ export const useWeighing = ({
}) })
} }
const nextStep = mode === 'tare' const nextStep = isFinal
? entity.value.currentStep ? entity.value.currentStep
: entity.value.currentStep + 1 : entity.value.currentStep + 1
await updateEntity(entity.value.id, { await updateEntity(entity.value.id, {
@@ -152,7 +154,7 @@ export const useWeighingShipment = ({
entity: shipment, entity: shipment,
entityName: 'shipment', entityName: 'shipment',
apiResource: 'shipments', apiResource: 'shipments',
titleLabel: modeShipment === 'gross' ? 'Pesée à vide' : 'Pesée à plein', titleLabel: modeShipment === 'gross' ? 'Pesée à plein' : 'Pesée à vide',
getWeightFromScale: async () => { getWeightFromScale: async () => {
const { getWeightShipment } = await import('~/services/shipment') const { getWeightShipment } = await import('~/services/shipment')
return getWeightShipment() return getWeightShipment()

View File

@@ -5,13 +5,13 @@ export const shipmentConfig: WorkflowConfig = {
apiResource: 'shipments', apiResource: 'shipments',
steps: [ steps: [
{ label: 'Expédition' }, { label: 'Expédition' },
{ label: 'Pesée à vide', weighingMode: 'gross' }, { label: 'Pesée à vide', weighingMode: 'tare' },
{ label: 'Chargement' }, { label: 'Chargement' },
{ label: 'Pesée à plein', weighingMode: 'tare', isFinal: true } { label: 'Pesée à plein', weighingMode: 'gross', isFinal: true }
], ],
weighingLabels: { weighingLabels: {
gross: 'Pesée à vide', gross: 'Pesée à plein',
tare: 'Pesée à plein' tare: 'Pesée à vide'
}, },
buildReceiptFilename: (entity: WorkflowEntity) => { buildReceiptFilename: (entity: WorkflowEntity) => {
const ship = entity as any const ship = entity as any

View File

@@ -89,6 +89,9 @@
"create": "Impossible de créer le type bovin.", "create": "Impossible de créer le type bovin.",
"update": "Impossible de mettre à jour le type bovin." "update": "Impossible de mettre à jour le type bovin."
}, },
"bovine": {
"create": "Impossible d'enregistrer le bovin."
},
"carrier": { "carrier": {
"list": "Impossible de récupérer la liste des transporteurs.", "list": "Impossible de récupérer la liste des transporteurs.",
"fetch": "Impossible de récupérer les données du transporteur", "fetch": "Impossible de récupérer les données du transporteur",
@@ -146,6 +149,9 @@
"update": "Type bovin mis à jour avec succès.", "update": "Type bovin mis à jour avec succès.",
"create": "Type bovin créé avec succès." "create": "Type bovin créé avec succès."
}, },
"bovine": {
"create": "Bovin enregistré avec succès."
},
"weight": { "weight": {
"update": "Pesée mis à jour" "update": "Pesée mis à jour"
} }

View File

@@ -122,6 +122,23 @@
Bovins Bovins
</a> </a>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/scan"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/scan')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Scanner
</a>
</NuxtLink>
</nav> </nav>
<!-- Spacer mobile (pour centrer visuellement le header si besoin) --> <!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
@@ -218,6 +235,9 @@
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu"> <NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
Bovins Bovins
</NuxtLink> </NuxtLink>
<NuxtLink to="/scan" @click="closeMenu">
Scanner
</NuxtLink>
</nav> </nav>
<button <button
@@ -231,7 +251,7 @@
</aside> </aside>
</transition> </transition>
</header> </header>
<main class="mx-auto w-full max-w-[1280px] mt-16"> <main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
<slot/> <slot/>
</main> </main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3"> <footer class="w-full mt-auto bg-primary-500 px-6 py-3">

View File

@@ -1,33 +1,31 @@
<template> <template>
<div class="flex items-center justify-between "> <div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1> <h1 class="text-4xl font-bold uppercase text-primary-500">Liste des types bovins</h1>
</div> </div>
<div class="mt-7 border border-slate-200 mb-11 ">
<div class="grid grid-cols-2 gap-4 text-primary-700 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> <div v-if="auth.isAdmin" class="mt-7 mb-11">
<div>Nom</div> <UiDataTable
<div>Code</div> v-model:page="page"
</div> v-model:per-page="perPage"
<div v-if="!auth.isAdmin" class="px-4 py-6 text-slate-400"> :columns="columns"
Accès réservé aux administrateurs. :items="items"
</div> :total-items="totalItems"
<div v-else-if="bovinList.length === 0" class="px-4 py-6 text-slate-400"> :loading="loading"
Aucun type de bovin. row-clickable
</div> @row-click="goToBovin"
<template v-else> >
<div <template #header-label>
v-for="bovin in bovinList" <UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
:key="bovin.id" </template>
class="grid grid-cols-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200" <template #header-code>
role="button" <UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
tabindex="0" </template>
@click="goToBovin(bovin.id)" </UiDataTable>
@keydown.enter="goToBovin(bovin.id)"
>
<div>{{ bovin.label }}</div>
<div>{{ bovin.code }}</div>
</div>
</template>
</div> </div>
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<NuxtLink <NuxtLink
to="/admin/bovin" to="/admin/bovin"
@@ -35,24 +33,37 @@
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'" :class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
@click="handleAddClick" @click="handleAddClick"
> >
<Icon name="mdi:plus" size="28" /> <Icon name="mdi:plus" size="28" />
Ajouter Ajouter
</NuxtLink> </NuxtLink>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getBovineTypeList } from "~/services/bovine-type" import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import type { BovineTypeData } from "~/services/dto/bovine-type-data" import { useAuthStore } from '~/stores/auth'
import { useAuthStore } from "~/stores/auth" import { useDataTableServerState } from '~/composables/useDataTableServerState'
const bovinList = ref<BovineTypeData[]>([])
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const goToBovin = (id: number) => { const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<BovineTypeData>(
'bovine_types',
{
label: '',
code: ''
}
)
const columns = [
{ key: 'label', label: 'Nom' },
{ key: 'code', label: 'Code' }
]
const goToBovin = (bovin: BovineTypeData) => {
if (!auth.isAdmin) return if (!auth.isAdmin) return
router.push(`/admin/bovin/${id}`) router.push(`/admin/bovin/${bovin.id}`)
} }
const handleAddClick = (event: Event) => { const handleAddClick = (event: Event) => {
@@ -60,8 +71,7 @@ const handleAddClick = (event: Event) => {
event.preventDefault() event.preventDefault()
} }
onMounted(async () => { onMounted(() => {
if (!auth.isAdmin) return if (auth.isAdmin) reload()
bovinList.value = await getBovineTypeList()
}) })
</script> </script>

View File

@@ -1,51 +1,62 @@
<template> <template>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between "> <h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1> </div>
<div class="mt-7 mb-11">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
row-clickable
@row-click="goToCarrier"
>
<template #header-name>
<UiTextInput v-model="filters.name" placeholder="Label" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
</UiDataTable>
</div> </div>
<div class="mt-7 border border-slate-200 mb-11 ">
<div class="grid grid-cols-2 gap-4 text-primary-700 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Label</div>
<div>Code</div>
</div>
<div
v-for="carrier in carrierList"
:key="carrier.id"
class="grid grid-cols-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToCarrier(carrier.id)"
@keydown.enter="goToCarrier(carrier.id)"
>
<div>{{ carrier.name}}</div>
<div>{{ carrier.code }}</div>
</div>
</div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<NuxtLink <NuxtLink
to="/admin/carrier" to="/admin/carrier"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2" class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
> >
<Icon name="mdi:plus" size="28" /> <Icon name="mdi:plus" size="28" />
Ajouter Ajouter
</NuxtLink> </NuxtLink>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {CarrierData} from "~/services/dto/carrier-data"; import type { CarrierData } from '~/services/dto/carrier-data'
import {getCarrierList} from "~/services/carrier"; import { useDataTableServerState } from '~/composables/useDataTableServerState'
const carrierList = ref<CarrierData[]>()
const router = useRouter() const router = useRouter()
const goToCarrier = (id: number) => { const { items, totalItems, page, perPage, filters, loading, reload } =
router.push(`/admin/carrier/${id}`) useDataTableServerState<CarrierData>(
'carriers',
{
name: '',
code: ''
}
)
const columns = [
{ key: 'name', label: 'Label' },
{ key: 'code', label: 'Code' }
]
const goToCarrier = (carrier: CarrierData) => {
router.push(`/admin/carrier/${carrier.id}`)
} }
onMounted(async () => { onMounted(reload)
carrierList.value = await getCarrierList(false)
})
</script> </script>

View File

@@ -3,37 +3,35 @@
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1> <h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
</div> </div>
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11"> <div v-if="auth.isAdmin" class="mt-7 mb-11">
<div class="max-h-96 overflow-y-auto"> <UiDataTable
<div v-model:page="page"
class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide" v-model:per-page="perPage"
> :columns="columns"
<div>Nom</div> :items="items"
<div>Téléphone</div> :total-items="totalItems"
<div>Mail</div> :loading="loading"
<div>Créé par</div> row-clickable
</div> @row-click="goToCustomer"
>
<div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400"> <template #header-name>
Aucun client. <UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</div> </template>
<template #header-phone>
<div <UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
v-for="customer in customerList" </template>
:key="customer.id" <template #header-email>
class="grid grid-cols-4 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer" <UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
@click="goToCustomer(customer.id)" </template>
> <template #header-createdBy.username>
<div class="truncate">{{ customer.name || "—" }}</div> <UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
<div class="truncate">{{ customer.phone || "—" }}</div> </template>
<div class="truncate">{{ customer.email || "—" }}</div> </UiDataTable>
<div class="truncate">{{ customer.createdBy?.username || "—" }}</div>
</div>
</div>
</div> </div>
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400"> <div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs. Accès réservé aux administrateurs.
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<NuxtLink <NuxtLink
to="/admin/customer" to="/admin/customer"
@@ -48,17 +46,34 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getCustomerList } from "~/services/customer" import type { CustomerData } from '~/services/dto/customer-data'
import type { CustomerData } from "~/services/dto/customer-data" import { useAuthStore } from '~/stores/auth'
import { useAuthStore } from "~/stores/auth" import { useDataTableServerState } from '~/composables/useDataTableServerState'
const customerList = ref<CustomerData[]>([])
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const goToCustomer = (id: number) => { const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<CustomerData>(
'customers',
{
name: '',
phone: '',
email: '',
'createdBy.username': ''
}
)
const columns = [
{ key: 'name', label: 'Nom' },
{ key: 'phone', label: 'Téléphone' },
{ key: 'email', label: 'Mail' },
{ key: 'createdBy.username', label: 'Créé par' }
]
const goToCustomer = (customer: CustomerData) => {
if (!auth.isAdmin) return if (!auth.isAdmin) return
router.push(`/admin/customer/${id}`) router.push(`/admin/customer/${customer.id}`)
} }
const handleAddClick = (event: Event) => { const handleAddClick = (event: Event) => {
@@ -66,8 +81,7 @@ const handleAddClick = (event: Event) => {
event.preventDefault() event.preventDefault()
} }
onMounted(async () => { onMounted(() => {
if (!auth.isAdmin) return if (auth.isAdmin) reload()
customerList.value = await getCustomerList()
}) })
</script> </script>

View File

@@ -3,37 +3,35 @@
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1> <h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
</div> </div>
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11"> <div v-if="auth.isAdmin" class="mt-7 mb-11">
<div class="max-h-96 overflow-y-auto"> <UiDataTable
<div v-model:page="page"
class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide" v-model:per-page="perPage"
> :columns="columns"
<div>Nom</div> :items="items"
<div>Téléphone</div> :total-items="totalItems"
<div>Mail</div> :loading="loading"
<div>Créé par</div> row-clickable
</div> @row-click="goToSupplier"
>
<div v-if="supplierList.length === 0" class="px-4 py-6 text-slate-400"> <template #header-name>
Aucun fournisseur. <UiTextInput v-model="filters.name" placeholder="Nom" size="compact" />
</div> </template>
<template #header-phone>
<div <UiTextInput v-model="filters.phone" placeholder="Téléphone" size="compact" />
v-for="supplier in supplierList" </template>
:key="supplier.id" <template #header-email>
class="grid grid-cols-4 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer" <UiTextInput v-model="filters.email" placeholder="Mail" size="compact" />
@click="goToSupplier(supplier.id)" </template>
> <template #header-createdBy.username>
<div class="truncate">{{ supplier.name || "—" }}</div> <UiTextInput v-model="filters['createdBy.username']" placeholder="Créé par" size="compact" />
<div class="truncate">{{ supplier.phone || "—" }}</div> </template>
<div class="truncate">{{ supplier.email || "—" }}</div> </UiDataTable>
<div class="truncate">{{ supplier.createdBy?.username || "—" }}</div>
</div>
</div>
</div> </div>
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400"> <div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs. Accès réservé aux administrateurs.
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<NuxtLink <NuxtLink
to="/admin/supplier" to="/admin/supplier"
@@ -48,17 +46,34 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getSupplierList } from "~/services/supplier" import type { SupplierData } from '~/services/dto/supplier-data'
import type { SupplierData } from "~/services/dto/supplier-data" import { useAuthStore } from '~/stores/auth'
import { useAuthStore } from "~/stores/auth" import { useDataTableServerState } from '~/composables/useDataTableServerState'
const supplierList = ref<SupplierData[]>([])
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const goToSupplier = (id: number) => { const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<SupplierData>(
'suppliers',
{
name: '',
phone: '',
email: '',
'createdBy.username': ''
}
)
const columns = [
{ key: 'name', label: 'Nom' },
{ key: 'phone', label: 'Téléphone' },
{ key: 'email', label: 'Mail' },
{ key: 'createdBy.username', label: 'Créé par' }
]
const goToSupplier = (supplier: SupplierData) => {
if (!auth.isAdmin) return if (!auth.isAdmin) return
router.push(`/admin/supplier/${id}`) router.push(`/admin/supplier/${supplier.id}`)
} }
const handleAddClick = (event: Event) => { const handleAddClick = (event: Event) => {
@@ -66,8 +81,7 @@ const handleAddClick = (event: Event) => {
event.preventDefault() event.preventDefault()
} }
onMounted(async () => { onMounted(() => {
if (!auth.isAdmin) return if (auth.isAdmin) reload()
supplierList.value = await getSupplierList()
}) })
</script> </script>

View File

@@ -41,10 +41,24 @@
type="password" type="password"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
wrapper-class="w-[280px]" wrapper-class="w-[280px]"
required :required="!userId"
/> />
</div> </div>
<div class="flex items-center mb-11">
<label class="flex items-center gap-2 cursor-pointer">
<input
id="user-locked"
v-model="form.isLocked"
type="checkbox"
:disabled="!auth.isAdmin"
class="w-5 h-5 accent-primary-500"
/>
<span class="text-sm text-primary-700">Verrouiller le compte</span>
</label>
<p class="ml-4 text-xs text-slate-400">Un compte verrouillé ne peut plus se connecter.</p>
</div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<UiButton <UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@@ -86,7 +100,8 @@ const resolveUserId = (param: unknown) => {
const form = reactive<UserFormData>({ const form = reactive<UserFormData>({
username: '', username: '',
password: '', password: '',
role: '' role: '',
isLocked: false
}) })
const hydrateFromUser = (user: UserData | null) => { const hydrateFromUser = (user: UserData | null) => {
@@ -99,6 +114,7 @@ const hydrateFromUser = (user: UserData | null) => {
const hasAdmin = roles.includes('ROLE_ADMIN') const hasAdmin = roles.includes('ROLE_ADMIN')
form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER' form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER'
form.password = '' form.password = ''
form.isLocked = user.isLocked ?? false
isHydrating.value = false isHydrating.value = false
} }
@@ -129,6 +145,7 @@ async function validate() {
const basePayload: UserPayload = { const basePayload: UserPayload = {
username: normalizedUsername, username: normalizedUsername,
roles: normalizedRole ? [normalizedRole] : undefined, roles: normalizedRole ? [normalizedRole] : undefined,
isLocked: form.isLocked,
} }
if (normalizedPassword) { if (normalizedPassword) {
basePayload.password = normalizedPassword basePayload.password = normalizedPassword

View File

@@ -3,31 +3,52 @@
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1> <h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
</div> </div>
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11"> <div v-if="auth.isAdmin" class="mt-7 mb-11">
<div class="grid grid-cols-2 text-primary-700 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> <UiDataTable
<div>Utilisateur</div> v-model:page="page"
<div>Role</div> v-model:per-page="perPage"
</div> :columns="columns"
<div v-if="userList.length === 0" class="px-4 py-6 text-slate-400"> :items="items"
Aucun utilisateur. :total-items="totalItems"
</div> :loading="loading"
<template v-else> row-clickable
<div @row-click="goToUser"
v-for="user in userList" >
:key="user.id" <template #header-username>
class="grid grid-cols-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200 items-center" <UiTextInput
role="button" v-model="filters.username"
tabindex="0" placeholder="Utilisateur"
@click="goToUser(user.id)" size="compact"
@keydown.enter="goToUser(user.id)" />
> </template>
<div>{{ user.username }}</div> <template #header-roles>
<div>{{ getRoleLabels(user.roles) }}</div> <UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
</div> </template>
</template> <template #header-isLocked>
<UiSelect
v-model="filters.isLocked"
placeholder="Statut"
:options="statusOptions"
size="compact"
/>
</template>
<template #cell-roles="{ item }">
{{ getRoleLabels(item.roles) }}
</template>
<template #cell-isLocked="{ item }">
<span
v-if="item.isLocked"
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
>Verrouillé</span>
<span
v-else
class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-green-100 text-green-700"
>Actif</span>
</template>
</UiDataTable>
</div> </div>
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400"> <div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
Acces reserve aux administrateurs. Accès réservé aux administrateurs.
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
@@ -44,19 +65,43 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from "~/services/dto/user-data" import type { UserData } from '~/services/dto/user-data'
import { getAdminUsers } from "~/services/auth" import { ROLE } from '~/utils/constants'
import { ROLE } from "~/utils/constants" import { useAuthStore } from '~/stores/auth'
import { useAuthStore } from "~/stores/auth" import { useDataTableServerState } from '~/composables/useDataTableServerState'
const userList = ref<UserData[]>([])
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label])) const roleLabelByValue = new Map(ROLE.map(role => [role.value, role.label]))
const goToUser = (id: number) => { const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<UserData>(
'admin/users',
{
username: '',
isLocked: ''
}
)
const statusOptions = [
{ value: 'false', label: 'Actif' },
{ value: 'true', label: 'Verrouillé' }
]
const columns = [
{ key: 'username', label: 'Utilisateur' },
{ key: 'roles', label: 'Role' },
{ key: 'isLocked', label: 'Statut', width: '160px' }
]
const getRoleLabels = (roles?: string[]) => {
if (!roles || roles.length === 0) return '---'
return roles.map(role => roleLabelByValue.get(role) ?? role).join(', ')
}
const goToUser = (user: UserData) => {
if (!auth.isAdmin) return if (!auth.isAdmin) return
router.push(`/admin/user/${id}`) router.push(`/admin/user/${user.id}`)
} }
const handleAddClick = (event: Event) => { const handleAddClick = (event: Event) => {
@@ -64,18 +109,7 @@ const handleAddClick = (event: Event) => {
event.preventDefault() event.preventDefault()
} }
const getRoleLabels = (roles?: string[]) => { onMounted(() => {
if (!roles || roles.length === 0) { if (auth.isAdmin) reload()
return '---'
}
return roles
.map((role) => roleLabelByValue.get(role) ?? role)
.join(', ')
}
onMounted(async () => {
if (!auth.isAdmin) return
userList.value = await getAdminUsers()
}) })
</script> </script>

View File

@@ -18,9 +18,9 @@
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" /> <card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" /> <card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" /> <card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
<card-link link="/" iconName="mdi:cow"> <card-link link="/inventory" iconName="mdi:cow">
<template #label> <template #label>
PASSEPORT<br>DU BOVIN INVENTAIRE<br>BOVINS
</template> </template>
</card-link> </card-link>
</div> </div>

View File

@@ -0,0 +1,180 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="goBack"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ isEdit ? 'Modification d\'un bovin' : 'Ajout d\'un bovin' }}
</h1>
</div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput
id="bovine-national-number"
v-model="form.nationalNumber"
label="Numéro national"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
required
/>
<UiNumberInput
id="bovine-received-weight"
v-model="form.receivedWeight"
label="Poids à l'arrivée (kg)"
:min="0"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px] flex-col"
label-class="font-bold uppercase"
/>
<UiDateInput
id="bovine-arrival-date"
v-model="form.arrivalDate"
label="Date d'arrivée"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
/>
</div>
<div class="flex flex-cols-3 justify-between mb-11">
<UiSelect
id="bovine-supplier"
v-model="form.supplierId"
label="Vendeur"
:options="supplierOptions"
:loading="isLoadingSuppliers"
:disabled="!auth.isAdmin || isLoading"
wrapper-class="w-[280px]"
/>
<div class="w-[280px]" />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton
type="submit"
:disabled="!auth.isAdmin || isLoading"
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
>
<Icon :name="isEdit ? '' : 'mdi:plus'" size="28" />
{{ isEdit ? 'Valider' : 'Ajouter' }}
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { createBovine, getBovine, updateBovine } from '~/services/bovine'
import type { BovinePayload } from '~/services/dto/bovine-data'
import type { SupplierData } from '~/services/dto/supplier-data'
import { getSupplierList } from '~/services/supplier'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const caseId = computed(() => {
const raw = Number(route.query.caseId)
return Number.isFinite(raw) && raw > 0 ? raw : null
})
const bovineId = computed(() => {
const raw = Number(route.query.id)
return Number.isFinite(raw) && raw > 0 ? raw : null
})
const isEdit = computed(() => bovineId.value !== null)
const form = reactive<{
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
supplierId: string
}>({
nationalNumber: '',
receivedWeight: null,
arrivalDate: null,
supplierId: ''
})
const isLoading = ref(false)
const submitted = ref(false)
const suppliers = ref<SupplierData[]>([])
const isLoadingSuppliers = ref(false)
const supplierOptions = computed(() =>
suppliers.value.map(s => ({ value: String(s.id), label: s.name }))
)
const backRoute = computed(() => ({
path: '/infrastructure/case',
query: caseId.value ? { id: String(caseId.value) } : {}
}))
const goBack = () => {
router.push(backRoute.value)
}
const loadSuppliers = async () => {
isLoadingSuppliers.value = true
try {
suppliers.value = await getSupplierList()
} finally {
isLoadingSuppliers.value = false
}
}
const hydrate = async () => {
if (!isEdit.value || bovineId.value === null) {
return
}
isLoading.value = true
try {
const bovine = await getBovine(bovineId.value)
form.nationalNumber = bovine.nationalNumber ?? ''
form.receivedWeight = bovine.receivedWeight ?? null
form.arrivalDate = bovine.arrivalDate ?? null
if (bovine.supplier) {
const supplierId = bovine.supplier.replace(/.*\//, '')
form.supplierId = supplierId
}
} finally {
isLoading.value = false
}
}
const validate = async () => {
if (isLoading.value || !auth.isAdmin) return
if (!caseId.value) return
if (!form.nationalNumber.trim()) return
const payload: BovinePayload = {
nationalNumber: form.nationalNumber.trim(),
receivedWeight: form.receivedWeight,
arrivalDate: form.arrivalDate,
buildingCase: `/api/building_cases/${caseId.value}`,
supplier: form.supplierId ? `/api/suppliers/${form.supplierId}` : null
}
isLoading.value = true
try {
if (isEdit.value && bovineId.value !== null) {
await updateBovine(bovineId.value, payload)
} else {
await createBovine(payload)
}
router.push(backRoute.value)
} finally {
isLoading.value = false
}
}
onMounted(loadSuppliers)
watch(bovineId, hydrate, { immediate: true })
</script>

View File

@@ -36,7 +36,7 @@
v-for="cell in entry.cells" v-for="cell in entry.cells"
:key="cell.key" :key="cell.key"
class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none" class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none"
:class="[cell.sideBorderClass, activeLegendStatutId !== null && cell.caseStatusId !== activeLegendStatutId ? 'opacity-35 hover:opacity-70' : '']" :class="[cell.sideBorderClass, activeLegendLabel !== null && cell.caseStatusLabel !== activeLegendLabel ? 'opacity-35 hover:opacity-70' : '']"
:style="[cell.spanStyle, cell.sideBorderStyle]" :style="[cell.spanStyle, cell.sideBorderStyle]"
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'" :to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
:title="cell.caseStatusLabel ?? undefined" :title="cell.caseStatusLabel ?? undefined"
@@ -58,25 +58,19 @@
<!-- Légende : survol d'un statut => atténue les autres cases --> <!-- Légende : survol d'un statut => atténue les autres cases -->
<div class="py-4"> <div class="py-4">
<!-- 3 zones fixes pour forcer gauche / centre / droite sur toute la largeur --> <div class="flex gap-6">
<div class="grid w-full grid-cols-3 gap-3">
<div <div
v-for="(statut, index) in statutLegend" v-for="statut in statutLegend"
:key="statut.id" :key="statut.label"
class="flex min-w-0 cursor-pointer items-center gap-2 py-1" class="flex cursor-pointer items-center gap-2 py-1"
:class="[ @mouseenter="activeLegendLabel = statut.label"
index === 0 ? 'justify-self-start' : '', @mouseleave="activeLegendLabel = null"
index === statutLegend.length - 1 ? 'justify-self-end' : '',
index > 0 && index < statutLegend.length - 1 ? 'justify-self-center' : ''
]"
@mouseenter="activeLegendStatutId = statut.id"
@mouseleave="activeLegendStatutId = null"
> >
<span <span
class="h-5 w-5 border border-slate-300" class="h-5 w-5 border border-slate-300"
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}" :style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
></span> ></span>
<span class="truncate text-sm uppercase text-slate-700"> <span class="text-sm uppercase text-slate-700">
{{ statut.label }} {{ statut.label }}
</span> </span>
</div> </div>
@@ -90,33 +84,35 @@
import type {BuildingData} from "~/services/dto/building-data" import type {BuildingData} from "~/services/dto/building-data"
import type {BuildingLayoutData} from "~/services/dto/building-layout-data" import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data" import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
import type {BuildingCaseStatusData} from "~/services/dto/building-case-status-data"
import {getBuildingList} from "~/services/building" import {getBuildingList} from "~/services/building"
import {getStatutList} from "~/services/statut"
definePageMeta({layout: "default"}) definePageMeta({layout: "default"})
const router = useRouter() const router = useRouter()
// Données brutes chargées depuis l'API // Données brutes chargées depuis l'API
const buildingList = ref<BuildingData[]>([]) const buildingList = ref<BuildingData[]>([])
const statutLegend = ref<BuildingCaseStatusData[]>([]) const statutLegend = [
{ label: 'Libre', couleur: '#A3B18A' },
{ label: 'Occupé', couleur: '#3A506B' },
{ label: 'Malade', couleur: '#E07A5F' },
]
// Statut actuellement survolé dans la légende (pour filtrage visuel) // Statut actuellement survolé dans la légende (pour filtrage visuel)
const activeLegendStatutId = ref<number | null>(null) const activeLegendLabel = ref<string | null>(null)
// Modèle de vue prêt pour le template (layout + cellules + styles de grille) // Modèle de vue prêt pour le template (layout + cellules + styles de grille)
const buildingLayouts = computed(() => const buildingLayouts = computed(() =>
buildingList.value.map((building) => { buildingList.value
// On affiche uniquement le premier layout du bâtiment .filter((building) => building.layouts && building.layouts.length > 0)
const layout = building.layouts?.[0] ?? null .map((building) => {
const view = layout ? buildLayoutView(layout) : null const layout = building.layouts![0]
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}} const view = buildLayoutView(layout)
}) return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
})
) )
type GridCell = { type GridCell = {
key: string key: string
caseId: number | null caseId: number | null
display: string display: string
caseStatusId: number | null
caseStatusLabel: string | null caseStatusLabel: string | null
// Couleur de fond de la case (dépend du statut) // Couleur de fond de la case (dépend du statut)
caseStyle?: Record<string, string> caseStyle?: Record<string, string>
@@ -130,7 +126,8 @@ type GridCell = {
contentInsetClass: string contentInsetClass: string
} }
// Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite // Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite
type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number } type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number}
// Nettoie la couleur de statut pour éviter les chaînes vides / espaces // Nettoie la couleur de statut pour éviter les chaînes vides / espaces
const normalizeCaseStatusColor = (value: string | null | undefined): string | null => { const normalizeCaseStatusColor = (value: string | null | undefined): string | null => {
@@ -181,7 +178,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
// Métadonnées utiles au rendu / navigation / légende // Métadonnées utiles au rendu / navigation / légende
const caseId = (position.buildingCase?.id ?? null) as number | null const caseId = (position.buildingCase?.id ?? null) as number | null
const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null
const caseStatusId = position.buildingCase?.statut?.id ?? null
const caseStatusLabel = position.buildingCase?.statut?.label ?? null const caseStatusLabel = position.buildingCase?.statut?.label ?? null
const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur) const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
@@ -191,7 +187,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
columnSpan, columnSpan,
caseId, caseId,
display: caseNumber !== null ? String(caseNumber) : "Case", display: caseNumber !== null ? String(caseNumber) : "Case",
caseStatusId,
caseStatusLabel, caseStatusLabel,
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined, caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne // Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
@@ -230,13 +225,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
} }
onMounted(async () => { onMounted(async () => {
// Chargement initial des bâtiments et de la légende des statuts buildingList.value = await getBuildingList()
const buildings = await getBuildingList()
const statuts = await getStatutList()
buildingList.value = buildings
// Tri alphabétique FR pour une légende stable
statutLegend.value = [...statuts].sort((a, b) =>
(a.label ?? "").localeCompare(b.label ?? "", "fr", {sensitivity: "base"})
)
}) })
</script> </script>

View File

@@ -1,27 +1,185 @@
<template> <template>
<div class="flex justify-center items-center"> <div class="px-[86px]">
<UiButton <div class="flex items-center justify-between relative">
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]" <div class="flex flex-row absolute -left-[60px]">
:disabled="!hasCaseId" <Icon
@click="printCaseReport" @click="router.push('/infrastructure/building')"
> name="gg:arrow-left-o"
Imprimer size="44"
</UiButton> class="cursor-pointer text-primary-500"
/>
</div>
<div class="flex items-center gap-4">
<h1 class="font-bold text-4xl text-primary-500 uppercase">
{{ title }}
</h1>
<div
v-if="hasCaseId"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
title="Imprimer"
@click="printCaseReport"
>
<Icon name="mdi:printer-outline" size="32" class="text-white" />
</div>
</div>
<NuxtLink
v-if="hasCaseId && auth.isAdmin"
:to="addBovineRoute"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div class="mt-8 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
:row-clickable="auth.isAdmin"
empty-message="Aucun bovin dans cette case."
@row-click="goToBovine"
>
<template #header-nationalNumber>
<UiTextInput
v-model="filters.nationalNumber"
placeholder="Numéro national"
size="compact"
/>
</template>
<template #header-receivedWeight>
<UiTextInput
v-model="filters.receivedWeight"
placeholder="Poids (kg)"
size="compact"
/>
</template>
<template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" placeholder="Date d'arrivée" size="compact" />
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-receivedWeight="{ item }">
{{ item.receivedWeight ?? '—' }}
</template>
</UiDataTable>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const route = useRoute() const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter() const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id)) const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0) const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
const printCaseReport = async () => { const buildingCase = ref<BuildingCaseData | null>(null)
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<BovineData>(
'bovines',
{
buildingCase: '',
nationalNumber: '',
receivedWeight: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const arrivalDateFilter = computed<string>({
get: () => (filters.value['arrivalDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value['arrivalDate[after]'] = ''
filters.value['arrivalDate[strictly_before]'] = ''
return
}
filters.value['arrivalDate[after]'] = value
filters.value['arrivalDate[strictly_before]'] = addOneDay(value)
}
})
const columns = [
{ key: 'nationalNumber', label: 'Numéro national' },
{ key: 'receivedWeight', label: "Poids à l'arrivée (kg)" },
{ key: 'arrivalDate', label: "Date d'arrivée" }
]
const title = computed(() => {
if (!buildingCase.value) return ''
const buildingLabel = buildingCase.value.building?.label ?? ''
const caseNumber = buildingCase.value.caseNumber ?? ''
return `${buildingLabel} case ${caseNumber}`.trim()
})
const addBovineRoute = computed(() => ({
path: '/infrastructure/bovine',
query: { caseId: String(caseId.value) }
}))
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const loadCase = async () => {
if (!hasCaseId.value) { if (!hasCaseId.value) {
buildingCase.value = null
return return
} }
buildingCase.value = await api.get<BuildingCaseData>(`/building_cases/${caseId.value}`)
}
const printCaseReport = async () => {
if (!hasCaseId.value) return
const filename = `tableau_poids_case_${caseId.value}.pdf` const filename = `tableau_poids_case_${caseId.value}.pdf`
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename) await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
} }
const goToBovine = (bovine: BovineData) => {
if (!auth.isAdmin) return
router.push({
path: '/infrastructure/bovine',
query: { id: String(bovine.id), caseId: String(caseId.value) }
})
}
watch(caseId, (id) => {
if (!hasCaseId.value) {
filters.value.buildingCase = ''
buildingCase.value = null
return
}
filters.value.buildingCase = `/api/building_cases/${id}`
loadCase()
reload()
}, { immediate: true })
</script> </script>

View File

@@ -0,0 +1,217 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="font-bold text-3xl uppercase text-primary-500">Inventaire bovins</h1>
<button
v-if="auth.isAdmin"
type="button"
:disabled="syncing"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2 disabled:cursor-not-allowed disabled:opacity-60"
@click="syncInventory"
>
<Icon name="mdi:sync" size="28" :class="syncing ? 'animate-spin' : ''" />
Rafraîchir
</button>
</div>
<div class="mt-6 mb-16">
<UiDataTable
v-model:page="page"
v-model:per-page="perPage"
:columns="columns"
:items="items"
:total-items="totalItems"
:loading="loading"
:row-class="rowClass"
>
<template #header-nationalNumber>
<UiTextInput
v-model="filters.nationalNumber"
placeholder="N° National"
size="compact"
/>
</template>
<template #header-workNumber>
<UiTextInput
v-model="filters.workNumber"
placeholder="N° Travail"
size="compact"
/>
</template>
<template #header-sex>
<UiSelect
v-model="filters.sex"
placeholder="Sexe"
:options="sexOptions"
size="compact"
/>
</template>
<template #header-birthDate>
<UiDateMaskedInput v-model="birthDateFilter" size="compact" placeholder="Né le" />
</template>
<template #header-breedCode>
<UiTextInput
v-model="filters.breedCode"
placeholder="Race"
size="compact"
/>
</template>
<template #header-arrivalDate>
<UiDateMaskedInput v-model="arrivalDateFilter" size="compact" placeholder="Entrée le" />
</template>
<template #header-buildingCase.building.label>
<UiTextInput :model-value="''" placeholder="Bâtiment" size="compact" disabled />
</template>
<template #header-buildingCase.caseNumber>
<UiTextInput :model-value="''" placeholder="Case" size="compact" disabled />
</template>
<template #header-age>
<UiTextInput :model-value="''" placeholder="Age" size="compact" disabled />
</template>
<template #cell-birthDate="{ item }">
{{ formatDate(item.birthDate) }}
</template>
<template #cell-age="{ item }">
{{ formatAgeLabel(item.ageMonths) }}
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-buildingCase.building.label="{ item }">
{{ item.buildingCase?.building?.label ?? '—' }}
</template>
<template #cell-buildingCase.caseNumber="{ item }">
{{ item.buildingCase?.caseNumber ?? '—' }}
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts">
import type { BovineData } from '~/services/dto/bovine-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
import { formatAgeLabel } from '~/utils/bovine-age'
const router = useRouter()
const auth = useAuthStore()
const api = useApi()
const toast = useToast()
interface SyncResult {
created: number
updated: number
exited: number
total: number
}
const syncing = ref(false)
const syncInventory = async () => {
if (syncing.value) return
const confirmed = window.confirm(
"Lancer la synchronisation avec EDNOTIF ?\n\nLes bovins absents de la réponse seront marqués comme sortis."
)
if (!confirmed) return
syncing.value = true
try {
const result = await api.post<SyncResult>('bovines/sync-inventory')
toast.success({
title: 'Inventaire synchronisé',
message: `Créés : ${result.created} · Mis à jour : ${result.updated} · Sortis : ${result.exited} · Total EDNOTIF : ${result.total}`
})
reload()
} catch {
// error toast already handled by useApi onResponseError
} finally {
syncing.value = false
}
}
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<BovineData>(
'bovines',
{
'exists[exitedAt]': 'false',
nationalNumber: '',
workNumber: '',
breedCode: '',
sex: '',
'arrivalDate[after]': '',
'arrivalDate[strictly_before]': '',
'birthDate[after]': '',
'birthDate[strictly_before]': ''
}
)
const sexOptions = [
{ value: 'M', label: 'Mâle' },
{ value: 'F', label: 'Femelle' }
]
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const singleDateFilter = (afterKey: string, beforeKey: string) =>
computed<string>({
get: () => (filters.value[afterKey] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value[afterKey] = ''
filters.value[beforeKey] = ''
return
}
filters.value[afterKey] = value
filters.value[beforeKey] = addOneDay(value)
}
})
const arrivalDateFilter = singleDateFilter('arrivalDate[after]', 'arrivalDate[strictly_before]')
const birthDateFilter = singleDateFilter('birthDate[after]', 'birthDate[strictly_before]')
const columns = [
{ key: 'nationalNumber', label: 'N° National', width: '160px' },
{ key: 'workNumber', label: 'N° Travail', width: '85px' },
{ key: 'sex', label: 'Sexe', width: '70px' },
{ key: 'birthDate', label: 'Né le', width: '120px' },
{ key: 'age', label: 'Age', width: '110px' },
{ key: 'breedCode', label: 'Race' },
{ key: 'buildingCase.building.label', label: 'Bâtiment', width: '1.5fr' },
{ key: 'buildingCase.caseNumber', label: 'Case', width: '80px' },
{ key: 'arrivalDate', label: 'Entrée le', width: '120px' }
]
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const rowClass = (item: BovineData): string => {
if (item.ageMonths === null || item.ageMonths === undefined) return ''
if (item.ageMonths >= 24) return 'bg-red-100 hover:bg-red-200'
if (item.ageMonths >= 22) return 'bg-orange-100 hover:bg-orange-200'
return ''
}
onMounted(reload)
</script>

View File

@@ -5,41 +5,127 @@
</div> </div>
<div class="px-[86px]"> <div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16 "> <div class="mt-6 mb-16">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> <UiDataTable
<div>Numéro</div> v-model:page="page"
<div>Date et heure</div> v-model:per-page="perPage"
<div>Fournisseur</div> :columns="columns"
<div>Adresse</div> :items="items"
<div>Type réception</div> :total-items="totalItems"
<div>Poids</div> :loading="loading"
</div> row-clickable
<div @row-click="goToReception"
v-for="reception in receptionList"
:key="reception.id"
class="grid grid-cols-6 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToReception(reception.id)"
> >
<div>{{ reception.identificationNumber}}</div> <template #header-identificationNumber>
<div>{{ formatDate(reception.receptionDate) }}</div> <UiTextInput
<div>{{ reception.supplier?.name }}</div> v-model="filters.identificationNumber"
<div>{{ reception.address?.fullAddress }}</div> placeholder="Numéro"
<div>{{ reception.receptionType?.label }}</div> size="compact"
<div>{{ formatWeighing(reception) }}</div> />
</div> </template>
<template #header-receptionDate>
<UiDateMaskedInput
v-model="receptionDateFilter"
placeholder="Date"
size="compact"
/>
</template>
<template #header-supplier.name>
<UiTextInput
v-model="filters['supplier.name']"
placeholder="Fournisseur"
size="compact"
/>
</template>
<template #header-address.fullAddress>
<UiTextInput
:model-value="''"
placeholder="Adresse"
size="compact"
disabled
/>
</template>
<template #header-receptionType.label>
<UiSelect
v-model="filters['receptionType.id']"
placeholder="Type réception"
:options="receptionTypeOptions"
size="compact"
/>
</template>
<template #header-weighing>
<UiTextInput
:model-value="''"
placeholder="Poids"
size="compact"
disabled
/>
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #cell-weighing="{ item }">
{{ formatWeighing(item) }}
</template>
</UiDataTable>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data"; import type { ReceptionData } from '~/services/dto/reception-data'
import {getReceptionList} from "~/services/reception"; import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import type {ShipmentData} from "~/services/dto/shipment-data"; import { getReceptionTypeList } from '~/services/reception-type'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const receptionList = ref<ReceptionData[]>()
const router = useRouter() const router = useRouter()
const receptionTypes = ref<ReceptionTypeData[]>([])
const receptionTypeOptions = computed(() =>
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
)
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<ReceptionData>(
'receptions',
{
isValid: true,
'identificationNumber': '',
'supplier.name': '',
'receptionType.id': '',
'receptionDate[after]': '',
'receptionDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const receptionDateFilter = computed<string>({
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value['receptionDate[after]'] = ''
filters.value['receptionDate[strictly_before]'] = ''
return
}
filters.value['receptionDate[after]'] = value
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
}
})
const columns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
{ key: 'receptionDate', label: 'Date', width: '120px' },
{ key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'receptionType.label', label: 'Type réception', width: '0.9fr' },
{ key: 'weighing', label: 'Poids', width: '82px' }
]
const formatDate = (date: string | null) => { const formatDate = (date: string | null) => {
if (!date) return '—' if (!date) return '—'
@@ -65,11 +151,12 @@ const formatWeighing = (reception: ReceptionData) => {
return `${gross - tare} kg` return `${gross - tare} kg`
} }
const goToReception = (id: number) => { const goToReception = (reception: ReceptionData) => {
router.push(`/reception/update/${id}`) router.push(`/reception/update/${reception.id}`)
} }
onMounted(async () => { onMounted(async () => {
receptionList.value = await getReceptionList(true) receptionTypes.value = await getReceptionTypeList()
reload()
}) })
</script> </script>

View File

@@ -1,40 +1,135 @@
<template> <template>
<WorkflowWaitingList <div class="flex items-center justify-start gap-10">
title="listes des réceptions en attente" <Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
:columns="columns" <h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1>
:items="receptionList ?? []" </div>
route-prefix="/reception"
show-actions <div class="px-[86px]">
> <div class="mt-6 mb-16">
<template #cell-receptionDate="{ item }"> <UiDataTable
{{ formatDate(item.receptionDate) }} v-model:page="page"
</template> v-model:per-page="perPage"
<template #actions="{ item }"> :columns="columns"
<Icon :items="items"
name="mdi:delete-outline" :total-items="totalItems"
size="24" :loading="loading"
class="cursor-pointer text-red-500 hover:text-red-700" :show-actions="auth.isAdmin"
@click="confirmDelete(item)" row-clickable
/> @row-click="goToReception"
</template> >
</WorkflowWaitingList> <template #header-receptionDate>
<UiDateMaskedInput v-model="receptionDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-supplier.name>
<UiTextInput
v-model="filters['supplier.name']"
placeholder="Fournisseur"
size="compact"
/>
</template>
<template #header-address.fullAddress>
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
</template>
<template #header-receptionType.label>
<UiSelect
v-model="filters['receptionType.id']"
placeholder="Type réception"
:options="receptionTypeOptions"
size="compact"
/>
</template>
<template #header-carrier.name>
<UiTextInput
v-model="filters['carrier.name']"
placeholder="Transporteur"
size="compact"
/>
</template>
<template #header-licensePlate>
<UiTextInput
v-model="filters['licensePlate']"
placeholder="Immatriculation"
size="compact"
/>
</template>
<template #header-actions>
<UiTextInput :model-value="''" placeholder="Actions" size="compact" disabled />
</template>
<template #cell-receptionDate="{ item }">
{{ formatDate(item.receptionDate) }}
</template>
<template #actions="{ item }">
<Icon
name="mdi:delete-outline"
size="24"
class="cursor-pointer text-red-500 hover:text-red-700"
@click="confirmDelete(item)"
/>
</template>
</UiDataTable>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ReceptionData } from '~/services/dto/reception-data' import type { ReceptionData } from '~/services/dto/reception-data'
import { getReceptionList, deleteReception } from '~/services/reception' import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { deleteReception } from '~/services/reception'
import { getReceptionTypeList } from '~/services/reception-type'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter()
const auth = useAuthStore()
const receptionTypes = ref<ReceptionTypeData[]>([])
const receptionTypeOptions = computed(() =>
receptionTypes.value.map(rt => ({ value: rt.id, label: rt.label }))
)
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<ReceptionData>(
'receptions',
{
isValid: false,
'supplier.name': '',
'carrier.name': '',
'licensePlate': '',
'receptionType.id': '',
'receptionDate[after]': '',
'receptionDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const receptionDateFilter = computed<string>({
get: () => (filters.value['receptionDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value['receptionDate[after]'] = ''
filters.value['receptionDate[strictly_before]'] = ''
return
}
filters.value['receptionDate[after]'] = value
filters.value['receptionDate[strictly_before]'] = addOneDay(value)
}
})
const columns = [ const columns = [
{ key: 'receptionDate', label: 'Date et heure' }, { key: 'receptionDate', label: 'Date', width: '120px' },
{ key: 'supplier.name', label: 'Fournisseur' }, { key: 'supplier.name', label: 'Fournisseur', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse' }, { key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'receptionType.label', label: 'Type réception' }, { key: 'receptionType.label', label: 'Type réception', width: '1.1fr' },
{ key: 'carrier.name', label: 'Transporteur' }, { key: 'carrier.name', label: 'Transporteur' },
{ key: 'licensePlate', label: 'Immatriculation' } { key: 'licensePlate', label: 'Immatriculation', width: '110px' }
] ]
const receptionList = ref<ReceptionData[]>()
const formatDate = (date: string | null) => { const formatDate = (date: string | null) => {
if (!date) return '—' if (!date) return '—'
const d = new Date(date.replace(' ', 'T')) const d = new Date(date.replace(' ', 'T'))
@@ -48,6 +143,10 @@ const formatDate = (date: string | null) => {
}) })
} }
const goToReception = (reception: ReceptionData) => {
router.push(`/reception/${reception.id}`)
}
const confirmDelete = async (reception: ReceptionData) => { const confirmDelete = async (reception: ReceptionData) => {
const confirmed = window.confirm( const confirmed = window.confirm(
`Êtes-vous sûr de vouloir supprimer la réception ${reception.identificationNumber ?? `#${reception.id}`} ? Toutes les données liées seront supprimées.` `Êtes-vous sûr de vouloir supprimer la réception ${reception.identificationNumber ?? `#${reception.id}`} ? Toutes les données liées seront supprimées.`
@@ -55,10 +154,11 @@ const confirmDelete = async (reception: ReceptionData) => {
if (!confirmed) return if (!confirmed) return
await deleteReception(reception.id) await deleteReception(reception.id)
receptionList.value = receptionList.value?.filter(r => r.id !== reception.id) reload()
} }
onMounted(async () => { onMounted(async () => {
receptionList.value = await getReceptionList(false) receptionTypes.value = await getReceptionTypeList()
reload()
}) })
</script> </script>

229
frontend/pages/scan.vue Normal file
View File

@@ -0,0 +1,229 @@
<template>
<div class="flex flex-col gap-4 px-4">
<h1 class="text-2xl text-primary-500 font-bold uppercase">Scanner des bovins</h1>
<!-- Message si non supporté -->
<div v-if="!scanner.isSupported.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700 flex flex-col w-full">
<p class="font-bold">Scanner non disponible</p>
<p class="text-sm mt-1">BarcodeDetector n'est pas supportée par ce navigateur. Utilisez Chrome sur Android.</p>
</div>
<!-- Erreur caméra -->
<div v-if="scanner.error.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700">
<p>{{ scanner.error.value }}</p>
</div>
<div class="flex flex-wrap gap-4">
<UiSelect
id="scan-building"
v-model="selectedBuildingId"
label="Bâtiment"
:options="buildingOptions"
wrapper-class="w-full max-w-[280px]"
required
/>
<UiSelect
id="scan-case"
v-model="selectedCaseId"
label="Case"
:options="caseOptions"
:disabled="!selectedBuildingId"
wrapper-class="w-full max-w-[280px]"
required
/>
</div>
<!-- Zone caméra pleine hauteur -->
<div v-if="showScanner" class="fixed inset-0 z-50 flex flex-col bg-black overflow-hidden">
<!-- Header scanner -->
<div class="flex items-center justify-between px-4 py-3 bg-black/90">
<div class="text-white text-sm font-semibold">
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
</div>
<UiButton
type="button"
class="text-md font-bold uppercase bg-red-500 text-white h-[40px] px-4"
@click="stopScanning"
>
Arrêter
</UiButton>
</div>
<div class="flex-1 relative">
<video
ref="videoRef"
class="w-full h-full object-cover"
playsinline
muted
/>
<!-- Overlay zone de scan -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-[90%] h-48 border-2 border-white/70 rounded-lg" />
</div>
<!-- Dernier scan -->
<div v-if="lastScanned" class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-green-600/80 text-white px-4 py-2 rounded-full text-sm font-semibold">
{{ lastScanned }}
</div>
</div>
</div>
<!-- Bouton démarrer -->
<div v-if="!showScanner" class="flex gap-3">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full max-w-[272px]"
:disabled="!scanner.isSupported.value"
@click="startScanning"
>
Démarrer le scanner
</UiButton>
</div>
<!-- Liste des numéros scannés -->
<div class="flex flex-col gap-3">
<p class="text-sm text-slate-500 font-semibold">
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
</p>
<div
v-for="(entry, index) in entries"
:key="index"
class="flex items-center gap-2"
>
<UiTextInput
:id="`scan-entry-${index}`"
:ref="(el: any) => setInputRef(el?.$el?.querySelector('input') ?? el, index)"
:model-value="entries[index]"
placeholder="Numéro national"
wrapper-class="flex-1 max-w-md"
@update:model-value="(val: string) => entries[index] = val ?? ''"
/>
<button
v-if="entries.length > 1"
type="button"
class="text-red-400 hover:text-red-600"
@click="removeEntry(index)"
>
<Icon name="mdi:close-circle" size="24" />
</button>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-4">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full"
:disabled="scannedCount === 0 || isSubmitting || !selectedCaseId"
:loading="isSubmitting"
@click="submit"
>
Valider ({{ scannedCount }})
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
import { createBovine } from '~/services/bovine'
import { getBuildingList } from '~/services/building'
import type { BuildingData } from '~/services/dto/building-data'
const videoRef = ref<HTMLVideoElement>()
const entries = ref<string[]>([''])
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const isSubmitting = ref(false)
const lastScanned = ref('')
const showScanner = ref(false)
const buildings = ref<BuildingData[]>([])
const selectedBuildingId = ref<string | number | null>(null)
const selectedCaseId = ref<string | number | null>(null)
const buildingOptions = computed(() =>
buildings.value.map(b => ({ value: b.id, label: b.label }))
)
const caseOptions = computed(() => {
const building = buildings.value.find(b => b.id === Number(selectedBuildingId.value))
if (!building?.buildingCases) return []
return [...building.buildingCases]
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
.map(c => ({
value: c.id,
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
}))
})
watch(selectedBuildingId, () => {
selectedCaseId.value = null
})
onMounted(async () => {
buildings.value = await getBuildingList()
})
const scannedCount = computed(() => entries.value.filter(e => e.trim() !== '').length)
function setInputRef(el: HTMLInputElement | null, index: number) {
inputRefs.value[index] = el
}
const scanner = useBarcodeScanner((code: string) => {
if (entries.value.some(e => e.trim() === code)) return
const emptyIndex = entries.value.findIndex(e => e.trim() === '')
if (emptyIndex !== -1) {
entries.value[emptyIndex] = code
} else {
entries.value.push(code)
}
lastScanned.value = code
entries.value.push('')
})
function startScanning() {
showScanner.value = true
nextTick(() => {
if (videoRef.value) {
scanner.start(videoRef.value)
}
})
}
function stopScanning() {
scanner.stop()
showScanner.value = false
lastScanned.value = ''
}
function removeEntry(index: number) {
entries.value.splice(index, 1)
inputRefs.value.splice(index, 1)
}
async function submit() {
const numbers = entries.value.filter(e => e.trim() !== '').map(e => e.trim())
if (numbers.length === 0 || !selectedCaseId.value) return
const caseIri = `/api/building_cases/${selectedCaseId.value}`
isSubmitting.value = true
try {
let successCount = 0
for (const nationalNumber of numbers) {
const result = await createBovine({ nationalNumber, buildingCase: caseIri })
if (result) successCount++
}
if (successCount > 0) {
clearAll()
}
} finally {
isSubmitting.value = false
}
}
</script>

View File

@@ -18,11 +18,11 @@
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/> <ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<WorkflowWeight <WorkflowWeight
v-if="storeShipment?.currentStep === 1" v-if="storeShipment?.currentStep === 1"
ref="grossWeightRef" ref="tareWeightRef"
mode="gross" mode="tare"
entity-name="shipment" entity-name="shipment"
api-resource="shipments" api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.gross" :title-label="shipmentConfig.weighingLabels.tare"
:is-final="false" :is-final="false"
:entity="storeShipment" :entity="storeShipment"
:get-weight-from-scale="getWeightShipment" :get-weight-from-scale="getWeightShipment"
@@ -34,11 +34,11 @@
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/> <ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<WorkflowWeight <WorkflowWeight
v-if="storeShipment?.currentStep === 3" v-if="storeShipment?.currentStep === 3"
ref="tareWeightRef" ref="grossWeightRef"
mode="tare" mode="gross"
entity-name="shipment" entity-name="shipment"
api-resource="shipments" api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.tare" :title-label="shipmentConfig.weighingLabels.gross"
:is-final="true" :is-final="true"
:entity="storeShipment" :entity="storeShipment"
:get-weight-from-scale="getWeightShipment" :get-weight-from-scale="getWeightShipment"

View File

@@ -5,51 +5,148 @@
</div> </div>
<div class="px-[86px]"> <div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16 "> <div class="mt-6 mb-16">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> <UiDataTable
<div>Numéro</div> v-model:page="page"
<div>Date</div> v-model:per-page="perPage"
<div>Client</div> :columns="columns"
<div>Adresse</div> :items="items"
<div>Type d'expéditon</div> :total-items="totalItems"
<div>Poids</div> :loading="loading"
</div> row-clickable
<div @row-click="goToShipment"
v-for="shipment in shipmentList"
:key="shipment
.id"
class="grid grid-cols-6 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goShipment(shipment.id)"
> >
<div>{{ shipment.identificationNumber }}</div> <template #header-identificationNumber>
<div>{{ shipment.shipmentDate }}</div> <UiTextInput
<div>{{ shipment.customer?.name }}</div> v-model="filters.identificationNumber"
<div>{{ shipment.address?.fullAddress }}</div> placeholder="Numéro"
<div> size="compact"
<template v-if="formatShipmentLines(shipment).length"> />
</template>
<template #header-shipmentDate>
<UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
</template>
<template #header-customer.name>
<UiTextInput
v-model="filters['customer.name']"
placeholder="Client"
size="compact"
/>
</template>
<template #header-address.fullAddress>
<UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
</template>
<template #header-shipmentType.label>
<UiSelect
v-model="filters['shipmentType.id']"
placeholder="Type d'expédition"
:options="shipmentTypeOptions"
size="compact"
/>
</template>
<template #header-weighing>
<UiTextInput :model-value="''" placeholder="Poids" size="compact" disabled />
</template>
<template #cell-shipmentDate="{ item }">
{{ formatDate(item.shipmentDate) }}
</template>
<template #cell-shipmentType.label="{ item }">
<template v-if="formatShipmentLines(item).length">
<div <div
v-for="(line, index) in formatShipmentLines(shipment)" v-for="(line, index) in formatShipmentLines(item)"
:key="index" :key="index"
class="leading-5" class="leading-5"
> >
{{ line }} {{ line }}
</div> </div>
</template> </template>
</div> <template v-else></template>
<div>{{ formatWeighing(shipment) }}</div> </template>
</div> <template #cell-weighing="{ item }">
{{ formatWeighing(item) }}
</template>
</UiDataTable>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {ShipmentData} from "~/services/dto/shipment-data"; import type { ShipmentData } from '~/services/dto/shipment-data'
import {getShipmentList} from "~/services/shipment"; import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { getShipmentTypeList } from '~/services/shipment-type'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const shipmentList = ref<ShipmentData[]>()
const router = useRouter() const router = useRouter()
const shipmentTypes = ref<ShipmentTypeData[]>([])
const shipmentTypeOptions = computed(() =>
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
)
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<ShipmentData>(
'shipments',
{
isValid: true,
'identificationNumber': '',
'customer.name': '',
'shipmentType.id': '',
'shipmentDate[after]': '',
'shipmentDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const shipmentDateFilter = computed<string>({
get: () => (filters.value['shipmentDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value['shipmentDate[after]'] = ''
filters.value['shipmentDate[strictly_before]'] = ''
return
}
filters.value['shipmentDate[after]'] = value
filters.value['shipmentDate[strictly_before]'] = addOneDay(value)
}
})
const columns = [
{ key: 'identificationNumber', label: 'Numéro', width: '75px' },
{ key: 'shipmentDate', label: 'Date', width: '120px' },
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'shipmentType.label', label: "Type d'expédition", width: '1.1fr' },
{ key: 'weighing', label: 'Poids', width: '82px' }
]
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const formatShipmentLines = (shipment: ShipmentData) => {
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
return []
}
const label = typeof shipment.shipmentType === 'string'
? shipment.shipmentType
: shipment.shipmentType?.label
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
}
const formatWeighing = (shipment: ShipmentData) => { const formatWeighing = (shipment: ShipmentData) => {
const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
@@ -62,24 +159,12 @@ const formatWeighing = (shipment: ShipmentData) => {
return `${gross - tare} kg` return `${gross - tare} kg`
} }
const goToShipment = (shipment: ShipmentData) => {
const formatShipmentLines = (shipment: ShipmentData) => { router.push(`/shipment/update/${shipment.id}`)
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
return []
}
const label = typeof shipment.shipmentType === 'string'
? shipment.shipmentType
: shipment.shipmentType?.label
return [`${label ?? ''} : ${shipment.nbBovinSend ?? ''}`]
}
const goShipment = (id: number) => {
router.push(`/shipment/update/${id}`)
} }
onMounted(async () => { onMounted(async () => {
shipmentList.value = await getShipmentList(true) shipmentTypes.value = await getShipmentTypeList()
reload()
}) })
</script> </script>

View File

@@ -149,16 +149,6 @@
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60"> <div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1 <h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer" class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[ :class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50', activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : '' hasTareWeightError ? '!text-red-500 !border-red-500' : ''
@@ -167,6 +157,16 @@
> >
pesée à vide pesée à vide
</h1> </h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
</div> </div>
<div class="mb-12"> <div class="mb-12">
<update-weight <update-weight
@@ -248,7 +248,7 @@ const hasTareWeightError = computed(() =>
submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null) submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null)
) )
const activeTab = ref<'weightsEmpty' | 'weights'>('weights') const activeTab = ref<'weightsEmpty' | 'weights'>('weightsEmpty')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross')) const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare')) const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const formIsLoading = ref(false) const formIsLoading = ref(false)

View File

@@ -1,52 +1,147 @@
<template> <template>
<WorkflowWaitingList <div class="flex items-center justify-start gap-10">
title="listes des expéditions en attente" <Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
:columns="columns" <h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1>
:items="shipmentList ?? []" </div>
route-prefix="/shipment"
show-actions <div class="px-[86px]">
> <div class="mt-6 mb-16">
<template #cell-shipmentDate="{ item }"> <UiDataTable
{{ formatDate(item.shipmentDate) }} v-model:page="page"
</template> v-model:per-page="perPage"
<template #cell-shipmentType="{ item }"> :columns="columns"
<template v-if="formatShipmentLines(item).length"> :items="items"
<div :total-items="totalItems"
v-for="(line, index) in formatShipmentLines(item)" :loading="loading"
:key="index" :show-actions="auth.isAdmin"
class="leading-5" row-clickable
> @row-click="goToShipment"
{{ line }} >
</div> <template #header-shipmentDate>
</template> <UiDateMaskedInput v-model="shipmentDateFilter" placeholder="Date" size="compact" />
<template v-else></template> </template>
</template> <template #header-customer.name>
<template #actions="{ item }"> <UiTextInput
<Icon v-model="filters['customer.name']"
name="mdi:delete-outline" placeholder="Client"
size="24" size="compact"
class="cursor-pointer text-red-500 hover:text-red-700" />
@click="confirmDelete(item)" </template>
/> <template #header-address.fullAddress>
</template> <UiTextInput :model-value="''" placeholder="Adresse" size="compact" disabled />
</WorkflowWaitingList> </template>
<template #header-shipmentType.label>
<UiSelect
v-model="filters['shipmentType.id']"
placeholder="Type d'expé."
:options="shipmentTypeOptions"
size="compact"
/>
</template>
<template #header-carrier.name>
<UiTextInput
v-model="filters['carrier.name']"
placeholder="Transporteur"
size="compact"
/>
</template>
<template #header-licensePlate>
<UiTextInput
v-model="filters['licensePlate']"
placeholder="Immatriculation"
size="compact"
/>
</template>
<template #header-actions>
<UiTextInput :model-value="''" placeholder="Actions" size="compact" disabled />
</template>
<template #cell-shipmentDate="{ item }">
{{ formatDate(item.shipmentDate) }}
</template>
<template #cell-shipmentType.label="{ item }">
<template v-if="formatShipmentLines(item).length">
<div
v-for="(line, index) in formatShipmentLines(item)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
<template v-else></template>
</template>
<template #actions="{ item }">
<Icon
name="mdi:delete-outline"
size="24"
class="cursor-pointer text-red-500 hover:text-red-700"
@click="confirmDelete(item)"
/>
</template>
</UiDataTable>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ShipmentData } from '~/services/dto/shipment-data' import type { ShipmentData } from '~/services/dto/shipment-data'
import { getShipmentList, deleteShipment } from '~/services/shipment' import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { deleteShipment } from '~/services/shipment'
import { getShipmentTypeList } from '~/services/shipment-type'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const router = useRouter()
const auth = useAuthStore()
const shipmentTypes = ref<ShipmentTypeData[]>([])
const shipmentTypeOptions = computed(() =>
shipmentTypes.value.map(st => ({ value: st.id, label: st.label }))
)
const { items, totalItems, page, perPage, filters, loading, reload } =
useDataTableServerState<ShipmentData>(
'shipments',
{
isValid: false,
'customer.name': '',
'carrier.name': '',
'licensePlate': '',
'shipmentType.id': '',
'shipmentDate[after]': '',
'shipmentDate[strictly_before]': ''
},
{ initialPerPage: 10 }
)
const addOneDay = (dateString: string): string => {
const [year, month, day] = dateString.split('-').map(Number)
const next = new Date(Date.UTC(year, month - 1, day + 1))
return next.toISOString().slice(0, 10)
}
const shipmentDateFilter = computed<string>({
get: () => (filters.value['shipmentDate[after]'] as string) ?? '',
set: (value: string) => {
if (!value) {
filters.value['shipmentDate[after]'] = ''
filters.value['shipmentDate[strictly_before]'] = ''
return
}
filters.value['shipmentDate[after]'] = value
filters.value['shipmentDate[strictly_before]'] = addOneDay(value)
}
})
const columns = [ const columns = [
{ key: 'shipmentDate', label: 'Date et heure' }, { key: 'shipmentDate', label: 'Date', width: '120px' },
{ key: 'customer.name', label: 'Client' }, { key: 'customer.name', label: 'Client', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse' }, { key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'shipmentType', label: "Type d'expé." }, { key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' },
{ key: 'carrier.name', label: 'Transporteur' }, { key: 'carrier.name', label: 'Transporteur' },
{ key: 'licensePlate', label: 'Immatriculation' } { key: 'licensePlate', label: 'Immatriculation', width: '110px' }
] ]
const shipmentList = ref<ShipmentData[]>()
const formatDate = (date: string | null) => { const formatDate = (date: string | null) => {
if (!date) return '—' if (!date) return '—'
const d = new Date(date.replace(' ', 'T')) const d = new Date(date.replace(' ', 'T'))
@@ -70,6 +165,10 @@ const formatShipmentLines = (shipment: ShipmentData) => {
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`] return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
} }
const goToShipment = (shipment: ShipmentData) => {
router.push(`/shipment/${shipment.id}`)
}
const confirmDelete = async (shipment: ShipmentData) => { const confirmDelete = async (shipment: ShipmentData) => {
const confirmed = window.confirm( const confirmed = window.confirm(
`Êtes-vous sûr de vouloir supprimer l'expédition ${shipment.identificationNumber ?? `#${shipment.id}`} ? Toutes les données liées seront supprimées.` `Êtes-vous sûr de vouloir supprimer l'expédition ${shipment.identificationNumber ?? `#${shipment.id}`} ? Toutes les données liées seront supprimées.`
@@ -77,10 +176,11 @@ const confirmDelete = async (shipment: ShipmentData) => {
if (!confirmed) return if (!confirmed) return
await deleteShipment(shipment.id) await deleteShipment(shipment.id)
shipmentList.value = shipmentList.value?.filter(s => s.id !== shipment.id) reload()
} }
onMounted(async () => { onMounted(async () => {
shipmentList.value = await getShipmentList(false) shipmentTypes.value = await getShipmentTypeList()
reload()
}) })
</script> </script>

View File

@@ -0,0 +1,42 @@
import { useApi } from '~/composables/useApi'
import type { BovineData, BovinePayload } from '~/services/dto/bovine-data'
export async function createBovine(payload: BovinePayload) {
const api = useApi()
return api.post<BovineData>('bovines', payload, {
headers: { 'Content-Type': 'application/ld+json' },
toastErrorKey: 'errors.bovine.create',
toastSuccessKey: 'success.bovine.create'
})
}
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
const created: BovineData[] = []
const errors: string[] = []
for (const nationalNumber of nationalNumbers) {
try {
const bovine = await createBovine({ nationalNumber })
if (bovine) {
created.push(bovine)
}
} catch {
errors.push(nationalNumber)
}
}
return { created, errors }
}
export async function getBovine(id: number) {
const api = useApi()
return api.get<BovineData>(`bovines/${id}`)
}
export async function updateBovine(id: number, payload: BovinePayload) {
const api = useApi()
return api.patch<BovineData>(`bovines/${id}`, payload, {
toastErrorKey: 'errors.bovine.update',
toastSuccessKey: 'success.bovine.update'
})
}

View File

@@ -0,0 +1,28 @@
export interface BovineBuildingCaseRef {
caseNumber: number | null
building: { label: string } | null
}
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
exitDate: string | null
buildingCase: BovineBuildingCaseRef | null
supplier: string | null
workNumber: string | null
birthDate: string | null
breedCode: string | null
sex: string | null
ageMonths: number | null
exitedAt: string | null
}
export type BovinePayload = {
nationalNumber?: string
receivedWeight?: number | null
arrivalDate?: string | null
buildingCase?: string | null
supplier?: string | null
}

View File

@@ -1,9 +1,17 @@
import type { BuildingCaseStatusData } from '~/services/dto/building-case-status-data' import type { BovineData } from '~/services/dto/bovine-data'
export interface BuildingSummary {
id: number
label: string
code: string
}
export interface BuildingCaseData { export interface BuildingCaseData {
id: number id: number
caseNumber: number | null caseNumber: number | null
code: string | null code: string | null
capacity: number | null capacity: number | null
statut?: BuildingCaseStatusData | null statut?: { label: string; couleur: string } | null
building?: BuildingSummary | null
bovines: BovineData[]
} }

View File

@@ -1,6 +0,0 @@
export interface BuildingCaseStatusData {
id: number
label: string | null
code: string | null
couleur: string | null
}

View File

@@ -1,8 +1,10 @@
import type { BuildingLayoutData } from '~/services/dto/building-layout-data' import type { BuildingLayoutData } from '~/services/dto/building-layout-data'
import type { BuildingCaseData } from '~/services/dto/building-case-data'
export interface BuildingData { export interface BuildingData {
id: number id: number
label: string label: string
code: string code: string
layouts?: BuildingLayoutData[] | null layouts?: BuildingLayoutData[] | null
buildingCases?: BuildingCaseData[] | null
} }

View File

@@ -2,16 +2,19 @@ export interface UserData {
id: number id: number
username: string username: string
roles: string[] roles: string[]
isLocked: boolean
} }
export type UserPayload = { export type UserPayload = {
username?: string username?: string
password?: string password?: string
roles?: string[] roles?: string[]
isLocked?: boolean
} }
export type UserFormData = { export type UserFormData = {
username: string username: string
password: string password: string
role: string role: string
isLocked: boolean
} }

View File

@@ -1,23 +0,0 @@
import { useApi } from '~/composables/useApi'
import type { BuildingCaseStatusData } from '~/services/dto/building-case-status-data'
export type StatutListResponse =
| BuildingCaseStatusData[]
| { 'hydra:member'?: BuildingCaseStatusData[] }
export async function getStatutList(): Promise<BuildingCaseStatusData[]> {
const api = useApi()
const response = await api.get<StatutListResponse>('statuts', {}, {
toastErrorKey: 'errors.http.get'
})
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}

View File

@@ -1,6 +1,6 @@
import {defineStore} from 'pinia' import {defineStore} from 'pinia'
import type {UserData} from '~/services/dto/user-data' import type {UserData} from '~/services/dto/user-data'
import {getCurrentUser, createUser, login, logout} from '~/services/auth' import {getCurrentUser, createUser, updateUser, login, logout} from '~/services/auth'
import type {UserPayload} from "~/services/dto/user-data"; import type {UserPayload} from "~/services/dto/user-data";
import {ROLE} from '~/utils/constants' import {ROLE} from '~/utils/constants'
@@ -58,7 +58,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async updateUser(id: number, payload: UserPayload) { async updateUser(id: number, payload: UserPayload) {
this.isLoading = true this.isLoading = true
const result = await createUser(payload).finally(() => { const result = await updateUser(id, payload).finally(() => {
this.isLoading = false this.isLoading = false
}) })
return result return result

View File

@@ -0,0 +1,10 @@
export const formatAgeLabel = (months: number | null | undefined): string => {
if (months === null || months === undefined) return '—'
const years = Math.floor(months / 12)
const remaining = months % 12
let label = ''
if (years > 0) label = `${years} an${years > 1 ? 's' : ''}`
if (remaining > 0) label += `${label ? ' ' : ''}${remaining} mois`
if (!label) label = '< 1 mois'
return label
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260325142815 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE "user" ADD is_locked BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE public."user" DROP is_locked');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260410065020 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD supplier_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD CONSTRAINT FK_2068337F2ADD6D8C FOREIGN KEY (supplier_id) REFERENCES supplier (id)');
$this->addSql('CREATE INDEX IDX_2068337F2ADD6D8C ON bovine (supplier_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP CONSTRAINT FK_2068337F2ADD6D8C');
$this->addSql('DROP INDEX IDX_2068337F2ADD6D8C');
$this->addSql('ALTER TABLE bovine DROP supplier_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260410074533 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD work_number VARCHAR(50) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD birth_date DATE DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD breed_code VARCHAR(20) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP work_number');
$this->addSql('ALTER TABLE bovine DROP birth_date');
$this->addSql('ALTER TABLE bovine DROP breed_code');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260410081839 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE building_case DROP CONSTRAINT fk_de2cee50f6203804');
$this->addSql('DROP INDEX idx_de2cee50f6203804');
$this->addSql('ALTER TABLE building_case DROP statut_id');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE building_case ADD statut_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE building_case ADD CONSTRAINT fk_de2cee50f6203804 FOREIGN KEY (statut_id) REFERENCES statut (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_de2cee50f6203804 ON building_case (statut_id)');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260410082723 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('DROP TABLE statut');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE statut (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, label VARCHAR(255) NOT NULL, code VARCHAR(255) NOT NULL, color VARCHAR(255) NOT NULL, PRIMARY KEY (id))');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260422155300 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD sex VARCHAR(1) DEFAULT NULL');
$this->addSql('ALTER TABLE bovine ADD exited_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP sex');
$this->addSql('ALTER TABLE bovine DROP exited_at');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260423062250 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD exit_date DATE DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP exit_date');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260424074454 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine ADD age_months INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE bovine DROP age_months');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
final class AnimalSummary
{
public ?string $countryCode = null;
public ?string $nationalNumber = null;
public ?string $sex = null;
public ?string $breedType = null;
public ?string $workNumber = null;
public ?string $birthDate = null;
public ?string $birthDateCompletenessFlag = null;
public ?bool $isFilie = null;
public ?string $motherNationalNumber = null;
public ?string $motherBreedType = null;
public ?string $fatherNationalNumber = null;
public ?string $fatherBreedType = null;
public ?string $birthExploitationNumber = null;
/** @var list<PresencePeriod> */
public array $presencePeriods = [];
}

View File

@@ -6,7 +6,7 @@ namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use App\State\AppVersionProvider; use App\State\System\AppVersionProvider;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(

View File

@@ -7,12 +7,14 @@ namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use App\State\BovinIdentificationProvider; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Bovin\BovinIdentificationProvider;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(
uriTemplate: '/bovins/{numeroNational}/identification', uriTemplate: '/bovins/{numeroNational}/identification',
openapi: new OpenApiOperation(tags: ['Bovins']),
provider: BovinIdentificationProvider::class provider: BovinIdentificationProvider::class
), ),
] ]

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Bovin\BovinInventoryProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/bovins/inventory/{startDate}',
openapi: new OpenApiOperation(tags: ['Bovins']),
provider: BovinInventoryProvider::class
),
]
)]
final class BovinInventory
{
#[ApiProperty(identifier: true)]
public string $startDate;
public ?string $endDate = null;
public ?bool $includesEarTagStock = null;
public int $nbBovins = 0;
public int $earTagSeriesCount = 0;
/** @var list<AnimalSummary> */
public array $animals = [];
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
final class BovinPresumedExit
{
public ?string $countryCode = null;
public ?string $nationalNumber = null;
public ?string $exitDate = null;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Bovin\BovinPresumedExitsProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/bovins/presumed-exits',
openapi: new OpenApiOperation(tags: ['Bovins']),
provider: BovinPresumedExitsProvider::class
),
]
)]
final class BovinPresumedExits
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
public int $nbBovins = 0;
/** @var list<BovinPresumedExit> */
public array $presumedExits = [];
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Bovin\BovinReturnedDossiersProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/bovins/returned-dossiers/{startDate}',
openapi: new OpenApiOperation(tags: ['Bovins']),
provider: BovinReturnedDossiersProvider::class
),
]
)]
final class BovinReturnedDossiers
{
#[ApiProperty(identifier: true)]
public string $startDate;
public int $nbBovins = 0;
/** @var list<AnimalSummary> */
public array $animals = [];
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Bovin\BovineSyncInventoryProcessor;
#[ApiResource(
operations: [
new Post(
uriTemplate: '/bovines/sync-inventory',
openapi: new OpenApiOperation(
summary: "Synchronise l'inventaire bovin local avec EDNOTIF.",
description: 'Upsert des bovins par numéro national ; marque comme sortis ceux absents de la réponse EDNOTIF.',
tags: ['Bovines'],
),
security: "is_granted('ROLE_ADMIN')",
input: false,
output: self::class,
processor: BovineSyncInventoryProcessor::class,
read: false,
),
]
)]
final class BovineSyncInventoryResult
{
#[ApiProperty(identifier: true)]
public string $id = 'current';
public int $created = 0;
public int $updated = 0;
public int $exited = 0;
public int $total = 0;
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Command;
use App\Entity\Bovine;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
use function count;
#[AsCommand(
name: 'app:enrich-bovines',
description: 'Enrichit les bovins existants avec les données EdNotif (n° travail, date naissance, race).'
)]
class EnrichBovinesCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly BovinApiInterface $bovinApi,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$bovines = $this->entityManager->getRepository(Bovine::class)->findBy(['workNumber' => null]);
if (0 === count($bovines)) {
$io->success('Tous les bovins sont déjà enrichis.');
return Command::SUCCESS;
}
$io->info(sprintf('%d bovin(s) à enrichir.', count($bovines)));
$enriched = 0;
$failed = 0;
foreach ($bovines as $bovine) {
try {
$animalFile = $this->bovinApi->getAnimalFile(
nationalNumber: $bovine->getNationalNumber(),
countryCode: 'FR',
);
$identification = $animalFile->identification;
if (null === $identification) {
$io->warning(sprintf(' %s — pas d\'identification retournée.', $bovine->getNationalNumber()));
++$failed;
continue;
}
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setBreedCode($this->normalizeBreedCode($identification->breedType));
++$enriched;
$io->text(sprintf(' ✓ %s → n° travail %s', $bovine->getNationalNumber(), $identification->workNumber ?? '—'));
} catch (Throwable $e) {
++$failed;
$io->warning(sprintf(' %s — erreur : %s', $bovine->getNationalNumber(), $e->getMessage()));
}
}
$this->entityManager->flush();
$io->success(sprintf('%d enrichi(s), %d échoué(s).', $enriched, $failed));
return Command::SUCCESS;
}
private function normalizeBreedCode(mixed $breedType): ?string
{
if (null === $breedType) {
return null;
}
if (is_numeric($breedType)) {
return (string) $breedType;
}
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
return $matches[0];
}
return null;
}
}

View File

@@ -18,7 +18,6 @@ use App\Entity\MerchandiseType;
use App\Entity\PelletType; use App\Entity\PelletType;
use App\Entity\ReceptionType; use App\Entity\ReceptionType;
use App\Entity\ShipmentType; use App\Entity\ShipmentType;
use App\Entity\Statut;
use App\Entity\Supplier; use App\Entity\Supplier;
use App\Entity\Truck; use App\Entity\Truck;
use App\Entity\Vehicle; use App\Entity\Vehicle;
@@ -230,24 +229,6 @@ class SeedCommand extends Command
private function seedBuildingInfrastructure(): void private function seedBuildingInfrastructure(): void
{ {
$statusByCode = [];
$statusRows = [
['label' => 'Libre', 'code' => 'LB', 'color' => '#A3B18A'],
['label' => 'Occupé', 'code' => 'OC', 'color' => '#3A506B'],
['label' => 'Malade', 'code' => 'ML', 'color' => '#E07A5F'],
];
foreach ($statusRows as $statusRow) {
/** @var Statut $status */
$status = $this->upsertByCode(Statut::class, $statusRow['code'], static function (Statut $entity) use ($statusRow) {
$entity
->setLabel($statusRow['label'])
->setCode($statusRow['code'])
->setColor($statusRow['color'])
;
});
$statusByCode[$statusRow['code']] = $status;
}
$buildingRepo = $this->entityManager->getRepository(Building::class); $buildingRepo = $this->entityManager->getRepository(Building::class);
$layoutByBuildingCode = []; $layoutByBuildingCode = [];
$layoutRows = [ $layoutRows = [
@@ -274,25 +255,15 @@ class SeedCommand extends Command
} }
$caseRows = [ $caseRows = [
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'status' => 'LB'], ['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'status' => 'OC'], ['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'status' => 'ML'], ['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 33, 'to' => 44, 'status' => 'LB'],
['buildingCode' => 'B2', 'from' => 1, 'to' => 10, 'status' => 'OC'],
['buildingCode' => 'B2', 'from' => 11, 'to' => 22, 'status' => 'LB'],
['buildingCode' => 'B2', 'from' => 23, 'to' => 30, 'status' => 'ML'],
['buildingCode' => 'B2', 'from' => 31, 'to' => 44, 'status' => 'OC'],
['buildingCode' => 'B3', 'from' => 1, 'to' => 8, 'status' => 'ML'],
['buildingCode' => 'B3', 'from' => 9, 'to' => 20, 'status' => 'LB'],
['buildingCode' => 'B3', 'from' => 21, 'to' => 34, 'status' => 'OC'],
['buildingCode' => 'B3', 'from' => 35, 'to' => 44, 'status' => 'ML'],
]; ];
$caseByCode = []; $caseByCode = [];
foreach ($caseRows as $caseRow) { foreach ($caseRows as $caseRow) {
$building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]); $building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]);
$status = $statusByCode[$caseRow['status']] ?? null; if (!$building instanceof Building) {
if (!$building instanceof Building || !$status instanceof Statut) {
continue; continue;
} }
@@ -300,13 +271,12 @@ class SeedCommand extends Command
$code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber); $code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber);
/** @var BuildingCase $buildingCase */ /** @var BuildingCase $buildingCase */
$buildingCase = $this->upsertByCode(BuildingCase::class, $code, static function (BuildingCase $entity) use ($code, $caseNumber, $building, $status) { $buildingCase = $this->upsertByCode(BuildingCase::class, $code, static function (BuildingCase $entity) use ($code, $caseNumber, $building) {
$entity $entity
->setCode($code) ->setCode($code)
->setCaseNumber($caseNumber) ->setCaseNumber($caseNumber)
->setCapacity(15) ->setCapacity(15)
->setIdBuilding($building) ->setIdBuilding($building)
->setStatut($status)
; ;
}); });
$caseByCode[$code] = $buildingCase; $caseByCode[$code] = $buildingCase;

View File

@@ -8,7 +8,6 @@ use App\Entity\Building;
use App\Entity\BuildingCase; use App\Entity\BuildingCase;
use App\Entity\BuildingCasePosition; use App\Entity\BuildingCasePosition;
use App\Entity\BuildingLayout; use App\Entity\BuildingLayout;
use App\Entity\Statut;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager; use Doctrine\Persistence\ObjectManager;
@@ -18,10 +17,9 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
{ {
public function load(ObjectManager $manager): void public function load(ObjectManager $manager): void
{ {
$statuts = $this->loadStatuts($manager);
$buildings = $this->getBuildingsByCode($manager, ['B1', 'B2', 'B3']); $buildings = $this->getBuildingsByCode($manager, ['B1', 'B2', 'B3']);
$layouts = $this->loadLayouts($manager, $buildings); $layouts = $this->loadLayouts($manager, $buildings);
$cases = $this->loadBuildingCases($manager, $buildings, $statuts); $cases = $this->loadBuildingCases($manager, $buildings);
$this->loadCasePositions($manager, $layouts, $cases); $this->loadCasePositions($manager, $layouts, $cases);
$manager->flush(); $manager->flush();
@@ -34,38 +32,6 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
]; ];
} }
/**
* @return array<string, Statut>
*/
private function loadStatuts(ObjectManager $manager): array
{
$repo = $manager->getRepository(Statut::class);
$data = [
['label' => 'Libre', 'code' => 'LB', 'color' => '#A3B18A'],
['label' => 'Occupé', 'code' => 'OC', 'color' => '#3A506B'],
['label' => 'Malade', 'code' => 'ML', 'color' => '#E07A5F'],
];
$result = [];
foreach ($data as $row) {
/** @var null|Statut $statut */
$statut = $repo->findOneBy(['code' => $row['code']]);
if (!$statut instanceof Statut) {
$statut = new Statut()
->setLabel($row['label'])
->setCode($row['code'])
->setColor($row['color'])
;
$manager->persist($statut);
}
$result[$row['code']] = $statut;
}
return $result;
}
/** /**
* @param list<string> $codes * @param list<string> $codes
* *
@@ -126,34 +92,21 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
/** /**
* @param array<string, Building> $buildings * @param array<string, Building> $buildings
* @param array<string, Statut> $statuts
* *
* @return array<string, BuildingCase> * @return array<string, BuildingCase>
*/ */
private function loadBuildingCases(ObjectManager $manager, array $buildings, array $statuts): array private function loadBuildingCases(ObjectManager $manager, array $buildings): array
{ {
$repo = $manager->getRepository(BuildingCase::class); $repo = $manager->getRepository(BuildingCase::class);
$statusRanges = [ $caseRanges = [
// B1 ['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'statut' => 'LB'], ['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'statut' => 'OC'], ['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'statut' => 'ML'],
['buildingCode' => 'B1', 'from' => 33, 'to' => 44, 'statut' => 'LB'],
// B2
['buildingCode' => 'B2', 'from' => 1, 'to' => 10, 'statut' => 'OC'],
['buildingCode' => 'B2', 'from' => 11, 'to' => 22, 'statut' => 'LB'],
['buildingCode' => 'B2', 'from' => 23, 'to' => 30, 'statut' => 'ML'],
['buildingCode' => 'B2', 'from' => 31, 'to' => 44, 'statut' => 'OC'],
// B3
['buildingCode' => 'B3', 'from' => 1, 'to' => 8, 'statut' => 'ML'],
['buildingCode' => 'B3', 'from' => 9, 'to' => 20, 'statut' => 'LB'],
['buildingCode' => 'B3', 'from' => 21, 'to' => 34, 'statut' => 'OC'],
['buildingCode' => 'B3', 'from' => 35, 'to' => 44, 'statut' => 'ML'],
]; ];
$result = []; $result = [];
foreach ($statusRanges as $range) { foreach ($caseRanges as $range) {
for ($caseNumber = $range['from']; $caseNumber <= $range['to']; ++$caseNumber) { for ($caseNumber = $range['from']; $caseNumber <= $range['to']; ++$caseNumber) {
$code = sprintf('%s-C%d', $range['buildingCode'], $caseNumber); $code = sprintf('%s-C%d', $range['buildingCode'], $caseNumber);
@@ -169,7 +122,6 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
->setCode($code) ->setCode($code)
->setCapacity(15) ->setCapacity(15)
->setIdBuilding($buildings[$range['buildingCode']]) ->setIdBuilding($buildings[$range['buildingCode']])
->setStatut($statuts[$range['statut']])
; ;
$manager->persist($buildingCase); $manager->persist($buildingCase);
} }

View File

@@ -4,11 +4,17 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Context;
@@ -16,8 +22,19 @@ use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity] #[ORM\Entity]
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'bovine')] #[ORM\Table(name: 'bovine')]
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])] #[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
#[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial',
'workNumber' => 'ipartial',
'breedCode' => 'ipartial',
'sex' => 'exact',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate', 'birthDate', 'exitDate'])]
#[ApiFilter(ExistsFilter::class, properties: ['exitedAt'])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(
@@ -31,12 +48,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['bovine:read']], normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']], denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
), ),
new Patch( new Patch(
requirements: ['id' => '\d+'], requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine:read']], normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']], denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
), ),
], ],
security: "is_granted('ROLE_USER')", security: "is_granted('ROLE_USER')",
@@ -46,26 +65,62 @@ class Bovine
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['bovine:read'])] #[Groups(['bovine:read', 'building_case:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private string $nationalNumber = ''; private string $nationalNumber = '';
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?int $receivedWeight = null; private ?int $receivedWeight = null;
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $arrivalDate = null; private ?DateTimeImmutable $arrivalDate = null;
#[ORM\ManyToOne(inversedBy: 'bovines')] #[ORM\ManyToOne(inversedBy: 'bovines')]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write'])]
#[ApiProperty(readableLink: true)]
private ?BuildingCase $buildingCase = null; private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?Supplier $supplier = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $workNumber = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $birthDate = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $breedCode = null;
#[ORM\Column(length: 1, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $sex = null;
#[ORM\Column(type: 'integer', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?int $ageMonths = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitDate = null;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $exitedAt = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -118,4 +173,114 @@ class Bovine
return $this; return $this;
} }
public function getSupplier(): ?Supplier
{
return $this->supplier;
}
public function setSupplier(?Supplier $supplier): static
{
$this->supplier = $supplier;
return $this;
}
public function getWorkNumber(): ?string
{
return $this->workNumber;
}
public function setWorkNumber(?string $workNumber): static
{
$this->workNumber = $workNumber;
return $this;
}
public function getBirthDate(): ?DateTimeImmutable
{
return $this->birthDate;
}
public function setBirthDate(?DateTimeImmutable $birthDate): static
{
$this->birthDate = $birthDate;
return $this;
}
public function getBreedCode(): ?string
{
return $this->breedCode;
}
public function setBreedCode(?string $breedCode): static
{
$this->breedCode = $breedCode;
return $this;
}
public function getSex(): ?string
{
return $this->sex;
}
public function setSex(?string $sex): static
{
$this->sex = $sex;
return $this;
}
public function getExitDate(): ?DateTimeImmutable
{
return $this->exitDate;
}
public function setExitDate(?DateTimeImmutable $exitDate): static
{
$this->exitDate = $exitDate;
return $this;
}
public function getExitedAt(): ?DateTimeImmutable
{
return $this->exitedAt;
}
public function setExitedAt(?DateTimeImmutable $exitedAt): static
{
$this->exitedAt = $exitedAt;
return $this;
}
public function getAgeMonths(): ?int
{
return $this->ageMonths;
}
public function setAgeMonths(?int $ageMonths): static
{
$this->ageMonths = $ageMonths;
return $this;
}
#[ORM\PrePersist]
#[ORM\PreUpdate]
public function refreshAgeMonths(): void
{
if (null === $this->birthDate) {
$this->ageMonths = null;
return;
}
$diff = $this->birthDate->diff(new DateTimeImmutable());
$this->ageMonths = ($diff->y * 12) + $diff->m;
}
} }

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
@@ -13,6 +15,10 @@ use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity] #[ORM\Entity]
#[ApiFilter(SearchFilter::class, properties: [
'label' => 'ipartial',
'code' => 'ipartial',
])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(

View File

@@ -32,15 +32,15 @@ class Building
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['building:read', 'reception:read'])] #[Groups(['building:read', 'building:summary', 'reception:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120)]
#[Groups(['building:read', 'reception:read'])] #[Groups(['building:read', 'building:summary', 'reception:read', 'bovine:read'])]
private string $label = ''; private string $label = '';
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
#[Groups(['building:read', 'reception:read'])] #[Groups(['building:read', 'building:summary', 'reception:read'])]
private string $code = ''; private string $code = '';
/** /**
@@ -53,6 +53,8 @@ class Building
* @var Collection<int, BuildingCase> * @var Collection<int, BuildingCase>
*/ */
#[ORM\OneToMany(targetEntity: BuildingCase::class, mappedBy: 'id_building')] #[ORM\OneToMany(targetEntity: BuildingCase::class, mappedBy: 'id_building')]
#[Groups(['building:read'])]
#[SerializedName('buildingCases')]
private Collection $buildingCases; private Collection $buildingCases;
/** /**

View File

@@ -7,7 +7,7 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\BuildingCaseWeightsReportProvider; use App\State\Building\BuildingCaseWeightsReportProvider;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
@@ -17,6 +17,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity] #[ORM\Entity]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['building_case:read', 'building:summary']],
),
new Get( new Get(
uriTemplate: '/building_cases/{id}/weights-report', uriTemplate: '/building_cases/{id}/weights-report',
requirements: ['id' => '\d+'], requirements: ['id' => '\d+'],
@@ -35,20 +39,20 @@ class BuildingCase
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['building:read'])] #[Groups(['building:read', 'building_case:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column] #[ORM\Column]
#[Groups(['building:read'])] #[Groups(['building:read', 'building_case:read', 'bovine:read'])]
#[SerializedName('caseNumber')] #[SerializedName('caseNumber')]
private ?int $case_number = null; private ?int $case_number = null;
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['building:read'])] #[Groups(['building:read', 'building_case:read'])]
private ?string $code = null; private ?string $code = null;
#[ORM\Column] #[ORM\Column]
#[Groups(['building:read'])] #[Groups(['building:read', 'building_case:read'])]
private ?int $capacity = null; private ?int $capacity = null;
/** /**
@@ -58,16 +62,15 @@ class BuildingCase
private Collection $id_case_position; private Collection $id_case_position;
#[ORM\ManyToOne(inversedBy: 'buildingCases')] #[ORM\ManyToOne(inversedBy: 'buildingCases')]
#[Groups(['building_case:read', 'bovine:read'])]
#[SerializedName('building')]
private ?Building $id_building = null; private ?Building $id_building = null;
#[ORM\ManyToOne(inversedBy: 'id_case')]
#[Groups(['building:read'])]
private ?Statut $statut = null;
/** /**
* @var Collection<int, Bovine> * @var Collection<int, Bovine>
*/ */
#[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'buildingCase')] #[ORM\OneToMany(targetEntity: Bovine::class, mappedBy: 'buildingCase')]
#[Groups(['building_case:read'])]
private Collection $bovines; private Collection $bovines;
public function __construct() public function __construct()
@@ -166,16 +169,17 @@ class BuildingCase
return $this; return $this;
} }
public function getStatut(): ?Statut /**
* @return array{label: string, couleur: string}
*/
#[Groups(['building:read', 'building_case:read'])]
public function getStatut(): array
{ {
return $this->statut; if ($this->bovines->count() > 0) {
} return ['label' => 'Occupé', 'couleur' => '#3A506B'];
}
public function setStatut(?Statut $statut): static return ['label' => 'Libre', 'couleur' => '#A3B18A'];
{
$this->statut = $statut;
return $this;
} }
/** /**

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
@@ -14,6 +16,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'carrier')] #[ORM\Table(name: 'carrier')]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
'code' => 'ipartial',
])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -17,6 +19,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'customer')] #[ORM\Table(name: 'customer')]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
'email' => 'ipartial',
'phone' => 'ipartial',
'createdBy.username' => 'ipartial',
])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
@@ -15,8 +17,8 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading; use App\Dto\PontBasculeReading;
use App\State\ReceptionReceiptProvider; use App\State\Reception\ReceptionReceiptProvider;
use App\State\ReceptionWeighingProvider; use App\State\Reception\ReceptionWeighingProvider;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -30,6 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')] #[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])] #[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'supplier.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licensePlate' => 'ipartial',
'receptionType.id' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['receptionDate'])]
#[ApiResource( #[ApiResource(
order: ['id' => 'DESC'], order: ['id' => 'DESC'],
operations: [ operations: [

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
@@ -15,8 +17,8 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading; use App\Dto\PontBasculeReading;
use App\State\ShipmentReceiptProvider; use App\State\Shipment\ShipmentReceiptProvider;
use App\State\ShipmentWeighingProvider; use App\State\Shipment\ShipmentWeighingProvider;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -30,6 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'shipment')] #[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])] #[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiFilter(SearchFilter::class, properties: [
'identificationNumber' => 'ipartial',
'customer.name' => 'ipartial',
'carrier.name' => 'ipartial',
'licensePlate' => 'ipartial',
'shipmentType.id' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['shipmentDate'])]
#[ApiResource( #[ApiResource(
order: ['id' => 'DESC'], order: ['id' => 'DESC'],
operations: [ operations: [

View File

@@ -1,138 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['building:read']],
),
new GetCollection(
normalizationContext: ['groups' => ['building:read']],
),
],
security: "is_granted('ROLE_USER')",
)]
class Statut
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['building:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['building:read'])]
private ?string $label = null;
#[ORM\Column(length: 255)]
#[Groups(['building:read'])]
private ?string $code = null;
#[ORM\Column(length: 255)]
#[Groups(['building:read'])]
#[SerializedName('couleur')]
private ?string $color = null;
/**
* @var Collection<int, BuildingCase>
*/
#[ORM\OneToMany(targetEntity: BuildingCase::class, mappedBy: 'statut')]
private Collection $id_case;
public function __construct()
{
$this->id_case = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function setId(int $id): static
{
$this->id = $id;
return $this;
}
public function getLabel(): ?string
{
return $this->label;
}
public function setLabel(string $label): static
{
$this->label = $label;
return $this;
}
public function getCode(): ?string
{
return $this->code;
}
public function setCode(string $code): static
{
$this->code = $code;
return $this;
}
public function getColor(): ?string
{
return $this->color;
}
public function setColor(string $color): static
{
$this->color = $color;
return $this;
}
/**
* @return Collection<int, BuildingCase>
*/
public function getIdCase(): Collection
{
return $this->id_case;
}
public function addIdCase(BuildingCase $idCase): static
{
if (!$this->id_case->contains($idCase)) {
$this->id_case->add($idCase);
$idCase->setStatut($this);
}
return $this;
}
public function removeIdCase(BuildingCase $idCase): static
{
if ($this->id_case->removeElement($idCase)) {
// set the owning side to null (unless already changed)
if ($idCase->getStatut() === $this) {
$idCase->setStatut(null);
}
}
return $this;
}
}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
@@ -17,6 +19,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'supplier')] #[ORM\Table(name: 'supplier')]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
'email' => 'ipartial',
'phone' => 'ipartial',
'createdBy.username' => 'ipartial',
])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(

View File

@@ -4,20 +4,27 @@ declare(strict_types=1);
namespace App\Entity; namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\State\MeProvider; use App\State\User\ActiveUsersProvider;
use App\State\UserPasswordProcessor; use App\State\User\MeProvider;
use App\State\User\UserPasswordProcessor;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface; use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'user', schema: 'public')] #[ORM\Table(name: 'user', schema: 'public')]
#[ApiFilter(SearchFilter::class, properties: ['username' => 'ipartial'])]
#[ApiFilter(BooleanFilter::class, properties: ['isLocked'])]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new Get( new Get(
@@ -45,12 +52,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
), ),
new GetCollection( new GetCollection(
normalizationContext: ['groups' => ['user-login:read']], normalizationContext: ['groups' => ['user-login:read']],
security: "is_granted('PUBLIC_ACCESS')" security: "is_granted('PUBLIC_ACCESS')",
provider: ActiveUsersProvider::class
), ),
new GetCollection( new GetCollection(
uriTemplate: '/admin/users', uriTemplate: '/admin/users',
normalizationContext: ['groups' => ['user:read']], normalizationContext: ['groups' => ['user:read']],
security: "is_granted('ROLE_ADMIN')" security: "is_granted('ROLE_ADMIN')",
paginationEnabled: true
), ),
], ],
normalizationContext: ['groups' => ['user:read']], normalizationContext: ['groups' => ['user:read']],
@@ -76,6 +85,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['user:write'])] #[Groups(['user:write'])]
private string $password = ''; private string $password = '';
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['user:read', 'user:write'])]
#[SerializedName('isLocked')]
private bool $isLocked = false;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -125,6 +139,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this; return $this;
} }
public function getIsLocked(): bool
{
return $this->isLocked;
}
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
public function eraseCredentials(): void public function eraseCredentials(): void
{ {
// No-op: we don't store temporary sensitive data on the entity. // No-op: we don't store temporary sensitive data on the entity.

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Security;
use App\Entity\User;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
use Symfony\Component\Security\Core\User\UserCheckerInterface;
use Symfony\Component\Security\Core\User\UserInterface;
final class UserChecker implements UserCheckerInterface
{
public function checkPreAuth(UserInterface $user): void
{
if (!$user instanceof User) {
return;
}
if ($user->getIsLocked()) {
throw new CustomUserMessageAccountStatusException('Ce compte est verrouillé.');
}
}
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\ApiResource\AnimalSummary;
use App\ApiResource\PresencePeriod;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
final class AnimalSummaryMapper
{
public function map(AnimalSummaryDto $dto): AnimalSummary
{
$summary = new AnimalSummary();
$identification = $dto->identification;
$summary->countryCode = $identification?->bovin?->countryCode;
$summary->nationalNumber = $identification?->bovin?->nationalNumber;
$summary->sex = $identification?->sex;
$summary->breedType = $identification?->breedType;
$summary->workNumber = $identification?->workNumber;
$summary->birthDate = $identification?->birthDate?->date?->format('Y-m-d');
$summary->birthDateCompletenessFlag = $identification?->birthDate?->completenessFlag;
$summary->isFilie = $identification?->isFilie;
$summary->motherNationalNumber = $identification?->motherCarrier?->bovin?->nationalNumber;
$summary->motherBreedType = $identification?->motherCarrier?->breedType;
$summary->fatherNationalNumber = $identification?->fatherIpg?->bovin?->nationalNumber;
$summary->fatherBreedType = $identification?->fatherIpg?->breedType;
$summary->birthExploitationNumber = $identification?->birthExploitation?->exploitationNumber;
foreach ($dto->presencePeriods as $presencePeriodDto) {
$presencePeriod = new PresencePeriod();
$presencePeriod->entryDate = $presencePeriodDto->entry?->date?->format('Y-m-d');
$presencePeriod->entryCause = $presencePeriodDto->entry?->cause;
$presencePeriod->exitDate = $presencePeriodDto->exit?->date?->format('Y-m-d');
$presencePeriod->exitCause = $presencePeriodDto->exit?->cause;
$summary->presencePeriods[] = $presencePeriod;
}
return $summary;
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BovinInventory;
use App\Service\AnimalSummaryMapper;
use DateTimeImmutable;
use Exception;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use function count;
use function is_string;
/**
* @implements ProviderInterface<null|BovinInventory>
*/
final class BovinInventoryProvider implements ProviderInterface
{
public function __construct(
private BovinApiInterface $bovinApi,
private RequestStack $requestStack,
private AnimalSummaryMapper $animalSummaryMapper,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?BovinInventory
{
$startDateRaw = (string) ($uriVariables['startDate'] ?? '');
if ('' === $startDateRaw) {
return null;
}
try {
$startDate = new DateTimeImmutable($startDateRaw);
} catch (Exception) {
return null;
}
$request = $this->requestStack->getCurrentRequest();
$endDate = null;
$endDateRaw = $request?->query->get('endDate');
if (is_string($endDateRaw) && '' !== $endDateRaw) {
try {
$endDate = new DateTimeImmutable($endDateRaw);
} catch (Exception) {
return null;
}
}
$includeEarTagStock = (bool) $request?->query->getBoolean('includeEarTagStock', false);
$inventoryDto = $this->bovinApi->getInventory(
startDate: $startDate,
endDate: $endDate,
includeEarTagStock: $includeEarTagStock,
);
$resource = new BovinInventory();
$resource->startDate = $inventoryDto->startDate?->format('Y-m-d') ?? $startDate->format('Y-m-d');
$resource->endDate = $inventoryDto->endDate?->format('Y-m-d');
$resource->includesEarTagStock = $inventoryDto->includesEarTagStock;
$resource->nbBovins = $inventoryDto->nbBovins;
$resource->earTagSeriesCount = count($inventoryDto->earTagSeries);
foreach ($inventoryDto->animals as $animalDto) {
$resource->animals[] = $this->animalSummaryMapper->map($animalDto);
}
return $resource;
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BovinPresumedExit;
use App\ApiResource\BovinPresumedExits;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
/**
* @implements ProviderInterface<BovinPresumedExits>
*/
final class BovinPresumedExitsProvider implements ProviderInterface
{
public function __construct(
private BovinApiInterface $bovinApi,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BovinPresumedExits
{
$dto = $this->bovinApi->getPresumedExits();
$resource = new BovinPresumedExits();
$resource->nbBovins = $dto->nbBovins;
foreach ($dto->presumedExits as $exitDto) {
$exit = new BovinPresumedExit();
$exit->countryCode = $exitDto->bovin?->countryCode;
$exit->nationalNumber = $exitDto->bovin?->nationalNumber;
$exit->exitDate = $exitDto->exitDate?->format('Y-m-d');
$resource->presumedExits[] = $exit;
}
return $resource;
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\BovinReturnedDossiers;
use App\Service\AnimalSummaryMapper;
use DateTimeImmutable;
use Exception;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
/**
* @implements ProviderInterface<null|BovinReturnedDossiers>
*/
final class BovinReturnedDossiersProvider implements ProviderInterface
{
public function __construct(
private BovinApiInterface $bovinApi,
private AnimalSummaryMapper $animalSummaryMapper,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?BovinReturnedDossiers
{
$startDateRaw = (string) ($uriVariables['startDate'] ?? '');
if ('' === $startDateRaw) {
return null;
}
try {
$startDate = new DateTimeImmutable($startDateRaw);
} catch (Exception) {
return null;
}
$dto = $this->bovinApi->getReturnedDossiers($startDate);
$resource = new BovinReturnedDossiers();
$resource->startDate = $dto->startDate?->format('Y-m-d') ?? $startDate->format('Y-m-d');
$resource->nbBovins = $dto->nbBovins;
foreach ($dto->animals as $animalDto) {
$resource->animals[] = $this->animalSummaryMapper->map($animalDto);
}
return $resource;
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Bovine;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Throwable;
final class BovineProcessor implements ProcessorInterface
{
public function __construct(
private readonly BovinApiInterface $bovinApi,
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private readonly ProcessorInterface $persistProcessor,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if ($data instanceof Bovine && '' !== $data->getNationalNumber()) {
$this->enrichFromEdnotif($data);
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
}
private function enrichFromEdnotif(Bovine $bovine): void
{
try {
$animalFile = $this->bovinApi->getAnimalFile(
nationalNumber: $bovine->getNationalNumber(),
countryCode: 'FR',
);
$identification = $animalFile->identification;
if (null === $identification) {
return;
}
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
$bovine->setBreedCode($this->normalizeBreedCode($identification->breedType));
} catch (Throwable) {
// External service unavailable — persist bovine without enrichment.
}
}
private function normalizeBreedCode(mixed $breedType): ?string
{
if (null === $breedType) {
return null;
}
if (is_numeric($breedType)) {
return (string) $breedType;
}
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
return $matches[0];
}
return null;
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\BovineSyncInventoryResult;
use App\Entity\Bovine;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Malio\EdnotifBundle\Bovin\Dto\AnimalSummaryDto;
/**
* @implements ProcessorInterface<mixed, BovineSyncInventoryResult>
*/
final class BovineSyncInventoryProcessor implements ProcessorInterface
{
public function __construct(
private BovinApiInterface $bovinApi,
private EntityManagerInterface $em,
) {}
public function process(
mixed $data,
Operation $operation,
array $uriVariables = [],
array $context = [],
): BovineSyncInventoryResult {
$inventory = $this->bovinApi->getInventory(new DateTimeImmutable('today'));
$result = new BovineSyncInventoryResult();
$result->total = count($inventory->animals);
$existingByNationalNumber = [];
foreach ($this->em->getRepository(Bovine::class)->findAll() as $bovine) {
$existingByNationalNumber[$bovine->getNationalNumber()] = $bovine;
}
$seen = [];
foreach ($inventory->animals as $animal) {
$nationalNumber = $animal->identification?->bovin?->nationalNumber;
if (null === $nationalNumber || '' === $nationalNumber) {
continue;
}
$seen[$nationalNumber] = true;
if (isset($existingByNationalNumber[$nationalNumber])) {
$bovine = $existingByNationalNumber[$nationalNumber];
++$result->updated;
} else {
$bovine = new Bovine();
$bovine->setNationalNumber($nationalNumber);
$this->em->persist($bovine);
++$result->created;
}
$this->applyEdnotifData($bovine, $animal);
$bovine->setExitedAt(null);
}
$now = new DateTimeImmutable();
foreach ($existingByNationalNumber as $nationalNumber => $bovine) {
if (isset($seen[$nationalNumber])) {
continue;
}
if (null !== $bovine->getExitedAt()) {
continue;
}
$bovine->setExitedAt($now);
++$result->exited;
}
$this->em->flush();
return $result;
}
private function applyEdnotifData(Bovine $bovine, AnimalSummaryDto $animal): void
{
$identification = $animal->identification;
if (null !== $identification) {
$bovine->setSex($identification->sex);
$bovine->setBreedCode($identification->breedType);
$bovine->setWorkNumber($identification->workNumber);
$bovine->setBirthDate($identification->birthDate?->date);
}
$latestEntry = null;
$latestExit = null;
foreach ($animal->presencePeriods as $period) {
if (null !== $period->entry?->date && (null === $latestEntry || $period->entry->date > $latestEntry)) {
$latestEntry = $period->entry->date;
}
if (null !== $period->exit?->date && (null === $latestExit || $period->exit->date > $latestExit)) {
$latestExit = $period->exit->date;
}
}
$bovine->setArrivalDate($latestEntry);
$bovine->setExitDate($latestExit);
$bovine->refreshAgeMonths();
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\Building;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
@@ -11,10 +11,8 @@ use App\Entity\BuildingCase;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
use Twig\Environment; use Twig\Environment;
use Twig\Error\LoaderError; use Twig\Error\LoaderError;
use Twig\Error\RuntimeError; use Twig\Error\RuntimeError;
@@ -40,7 +38,6 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
public function __construct( public function __construct(
private Environment $twig, private Environment $twig,
private EntityManagerInterface $entityManager, private EntityManagerInterface $entityManager,
private BovinApiInterface $bovinApi,
) {} ) {}
/** /**
@@ -68,24 +65,9 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
continue; continue;
} }
$workNumber = null; $breedCode = $bovine->getBreedCode();
$birthDate = null; if (null === $headerBreedCode && null !== $breedCode) {
$breedCode = null; $headerBreedCode = $breedCode;
try {
$animalFileDto = $this->bovinApi->getAnimalFile(
nationalNumber: $bovine->getNationalNumber(),
countryCode: 'FR',
);
$workNumber = $animalFileDto->identification?->workNumber;
$birthDate = $animalFileDto->identification?->birthDate?->date?->format('d/m/y');
$breedCode = $this->normalizeBreedCode($animalFileDto->identification?->breedType);
if (null === $headerBreedCode && null !== $breedCode) {
$headerBreedCode = $breedCode;
}
} catch (Throwable) {
// Keep row data even if external identification service is unavailable.
} }
$arrivalDate = $bovine->getArrivalDate(); $arrivalDate = $bovine->getArrivalDate();
@@ -101,8 +83,8 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
$rows[] = [ $rows[] = [
'nationalNumber' => $bovine->getNationalNumber(), 'nationalNumber' => $bovine->getNationalNumber(),
'workNumber' => $workNumber, 'workNumber' => $bovine->getWorkNumber(),
'birthDate' => $birthDate, 'birthDate' => $bovine->getBirthDate()?->format('d/m/y'),
'receivedWeight' => $bovine->getReceivedWeight(), 'receivedWeight' => $bovine->getReceivedWeight(),
'arrivalDate' => $bovine->getArrivalDate()?->format('d/m/Y'), 'arrivalDate' => $bovine->getArrivalDate()?->format('d/m/Y'),
'projectedWeights' => $projectedWeights, 'projectedWeights' => $projectedWeights,
@@ -131,23 +113,6 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
]); ]);
} }
private function normalizeBreedCode(mixed $breedType): ?string
{
if (null === $breedType) {
return null;
}
if (is_numeric($breedType)) {
return (string) $breedType;
}
if (is_string($breedType) && preg_match('/\d+/', $breedType, $matches)) {
return $matches[0];
}
return null;
}
private function resolveDailyGainKg(?string $breedCode): float private function resolveDailyGainKg(?string $breedCode): float
{ {
return 1.3; return 1.3;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\Reception;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\Reception;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\Shipment;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\Shipment;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\System;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\State\User;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
final readonly class ActiveUsersProvider implements ProviderInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
return $this->em->getRepository(User::class)->findBy(['isLocked' => false]);
}
}

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\User;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;

View File

@@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\State; namespace App\State\User;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;

View File

@@ -265,23 +265,15 @@
TABLEAU PRINCIPAL TABLEAU PRINCIPAL
========================= --> ========================= -->
<table class="main"> <table class="main">
<colgroup>
<col style="width:8%">
<col style="width:4%">
<col style="width:7%">
{% for month in monthHeaders %}
<col style="width:6.75%">
{% endfor %}
</colgroup>
<thead> <thead>
<tr> <tr>
<th rowspan="4" class="head-big">N° de<br>travail</th> <th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
<th rowspan="4" class="head-big head-big-weight">Poids<br>(kg)</th> <th rowspan="4" class="head-big" style="width:5%">N° de<br>travail</th>
<th rowspan="4" class="head-big">Date de<br>naissance</th> <th rowspan="4" class="head-big head-big-weight" style="width:4%">Poids<br>(kg)</th>
<th rowspan="4" class="head-big" style="width:7%">Date de<br>naissance</th>
{% for month in monthHeaders|default([]) %} {% for month in monthHeaders|default([]) %}
<th class="month">{{ month.name }}</th> <th class="month" style="width:6.58%">{{ month.name }}</th>
{% endfor %} {% endfor %}
</tr> </tr>
@@ -292,7 +284,9 @@
</tr> </tr>
<tr> <tr>
<th colspan="{{ monthHeaders|length }}" class="sub-title">POIDS PAR MOIS</th> <th class="days">Foin</th>
<th class="days">Foin</th>
<th colspan="{{ monthHeaders|length -2 }}" class="sub-title">POIDS PAR MOIS</th>
</tr> </tr>
<tr> <tr>
@@ -315,6 +309,7 @@
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %} {% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
<tr class="data-row"> <tr class="data-row">
<td class="row-work"></td>
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td> <td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
<td class="row-weight">{{ baseWeight ?? '' }}</td> <td class="row-weight">{{ baseWeight ?? '' }}</td>
<td class="row-birth"> <td class="row-birth">

View File

@@ -2,13 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace App\Tests\State; namespace App\Tests\State\Reception;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use App\Dto\PontBasculeReading; use App\Dto\PontBasculeReading;
use App\Service\PontBasculePayloadDecoder; use App\Service\PontBasculePayloadDecoder;
use App\Service\PontBasculeService; use App\Service\PontBasculeService;
use App\State\ReceptionWeighingProvider; use App\State\Reception\ReceptionWeighingProvider;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;