Compare commits

..

18 Commits

Author SHA1 Message Date
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
gitea-actions
fa7b44fb02 chore: bump version to v0.0.78
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 12m45s
2026-03-25 14:16:03 +00:00
9be2e0c379 [#FER-11] Corriger le problème de bearer token (!40)
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: #40
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 14:15:54 +00:00
gitea-actions
fee7bbb2ec chore: bump version to v0.0.77
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m47s
2026-03-24 07:33:27 +00:00
b707aae0e8 fix : bouton de mise en attente
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-24 08:33:13 +01:00
96 changed files with 3412 additions and 1039 deletions

210
.idea/workspace.xml generated
View File

@@ -4,10 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : order récéption/expédition + correction style bouton récéption">
<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$/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$/templates/reception_voucher.html.twig" beforeDir="false" afterPath="$PROJECT_DIR$/templates/reception_voucher.html.twig" 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>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -39,7 +41,7 @@
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="feat/327-voir-modifier-une-expedition-terminee" />
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -223,36 +225,36 @@
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;develop&quot;,
&quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;settings.editor.selected.configurable&quot;: &quot;advanced.settings&quot;,
&quot;ts.external.directory.path&quot;: &quot;/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"RunOnceActivity.MCP Project settings loaded": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"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",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "advanced.settings",
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
"vue.rearranger.settings.migration": "true"
},
&quot;keyToStringList&quot;: {
&quot;DatabaseDriversLRU&quot;: [
&quot;postgresql&quot;
"keyToStringList": {
"DatabaseDriversLRU": [
"postgresql"
],
&quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
&quot;TEXT&quot;
"com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [
"TEXT"
],
&quot;vue.recent.templates&quot;: [
&quot;Vue Composition API Component&quot;
"vue.recent.templates": [
"Vue Composition API Component"
]
}
}</component>
}]]></component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
@@ -324,71 +326,7 @@
<workItem from="1773766075191" duration="6202000" />
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="4910000" />
</task>
<task id="LOCAL-00029" summary="fix : gitea workflow">
<option name="closed" value="true" />
<created>1769097476629</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1769097476629</updated>
</task>
<task id="LOCAL-00030" summary="fix : script de déploiement">
<option name="closed" value="true" />
<created>1769098182184</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1769098182184</updated>
</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>
<workItem from="1774276665015" duration="33750000" />
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
@@ -718,7 +656,71 @@
<option name="project" value="LOCAL" />
<updated>1774283204849</updated>
</task>
<option name="localTasksCounter" value="78" />
<task id="LOCAL-00078" summary="fix : style bon de récéption">
<option name="closed" value="true" />
<created>1774285464091</created>
<option name="number" value="00078" />
<option name="presentableId" value="LOCAL-00078" />
<option name="project" value="LOCAL" />
<updated>1774285464091</updated>
</task>
<task id="LOCAL-00079" summary="fix : bouton de mise en attente">
<option name="closed" value="true" />
<created>1774337609427</created>
<option name="number" value="00079" />
<option name="presentableId" value="LOCAL-00079" />
<option name="project" value="LOCAL" />
<updated>1774337609427</updated>
</task>
<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 />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -768,15 +770,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat : creer une nouvelle expedtion (WIP)" />
<MESSAGE value="feat : ajout d'une page de creation d'une expedition" />
<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="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
@@ -793,7 +786,16 @@
<MESSAGE value="fix : script de déploiement + CI/CD build de l'app" />
<MESSAGE value="fix : order navbar + modification création fournisseur et client" />
<MESSAGE value="fix : order récéption/expédition + correction style bouton récéption" />
<option name="LAST_COMMIT_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 : 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 name="XDebuggerManager">
<breakpoint-manager>

View File

@@ -59,6 +59,13 @@ Ajouter dans le fichier .env du frontend
* [#356] front page admin bovin
* [#353] modification front admin client
* [#353] modification front admin utilisateur
* [#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
### Fixed

View File

@@ -142,6 +142,15 @@ frontend/
- `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`.
### 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
- `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.

View File

@@ -1,68 +1,87 @@
# Projet Ferme
# Projet Ferme t
## Installation du projet
### Windows
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
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)
### Installation du projet
Une fois les prérequis installés, il suffit de cloner le projet et de lancer les commandes suivantes
```bash
make start
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.
### 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.
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"
* PONT_BASCULE_BYPASS (doit être à true en dev)
* PONT_BASCULE_URL
* JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
* JWT_PUBLIC_KEY
* 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)
- 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"
- PONT_BASCULE_BYPASS (doit être à true en dev)
- PONT_BASCULE_URL
- JWT_SECRET_KEY (à générer avec la commande php bin/console lexik:jwt:generate-keypair)
- JWT_PUBLIC_KEY
- 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 :
* NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
- NUXT_PUBLIC_API_BASE="http://localhost:8080/api"
### Configuration xdebug
Pour configurer xdebug, il faut ajouter un serveur sur phpstorm. <br>
Pour cela, il faut aller dans **Settings > PHP > Servers** <br>
* Name : ferme-docker
* Host : localhost
* Port : 8080
* Path : File/Directory -> l'endroit où est stocké votre projet et le path -> /var/www/html
- Name : ferme-docker
- Host : localhost
- 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
## Utilisation du projet
### Backend
L'api est disponible sur http://localhost:8080/api
Pour la bdd toutes les infos sont dans le fichier **docker/.env.docker.local**
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.
C'est un bdd local dans le docker.
### Frontend
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
```bash
make dev-nuxt
```
Le front sera accessible sur http://localhost:3000
### Authentification
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.
### Login flow
- Frontend envoie les identifiants à:
- `POST /api/login_check`
- 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`.
### Fixtures
Pour lancer les fixtures (Attention sa purge la bdd complètement)
```bash
php bin/console doctrine:fixtures:load
```
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
```bash
php bin/console app:seed
```
La commande va faire une update ou une création en fonction des data existante.
## Livraison en recette
### Préparatifs
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 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>
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
### Livraison
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
```bash
```bash
/usr/local/bin/deploy-ferme vX.Y.Z
```
## Commandes utiles
Pour restart le container
```bash
make restart
```
Pour lancer les TU
```bash
make test
```
Pour accéder au container et lance des commandes
```bash
make shell
```
Pour clear le cache Symfony
```bash
make cache-clear
```
Faire une migration
```bash
make migration-migrate
```
Pour générer un password pour un user
```bash
make shell
php bin/console security:hash-password
```
Sélectionner entity User, taper sont mdp, le copier et l'ajouter dans l'insert de bdd suivant :
```sql
INSERT INTO "user" (username, roles, password)
VALUES ('Mon user', '["ROLE_USER"]', 'Mon mdp hashé');
```
## Gestion des logs
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",
"dompdf/dompdf": "^3.1",
"lexik/jwt-authentication-bundle": "*",
"malio/ednotif-bundle": ">=0.0.4",
"malio/ednotif-bundle": ">=0.0.6",
"nelmio/cors-bundle": "^2.6",
"phpdocumentor/reflection-docblock": "^5.6",
"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",
"This file is @generated automatically"
],
"content-hash": "9c04091eea0e10c19713a1d882b04f91",
"content-hash": "fd62fc3833815b11aa058fd2759c1c79",
"packages": [
{
"name": "api-platform/doctrine-common",
@@ -2706,11 +2706,11 @@
},
{
"name": "malio/ednotif-bundle",
"version": "v0.0.4",
"version": "v0.0.6",
"source": {
"type": "git",
"url": "https://gitea.malio.fr/MALIO-DEV/ednotif-bundle",
"reference": "92c058213b34ba61f4aa6c03e11ce1ea8cc71421"
"reference": "f757822f366bd5f55588aa89e0ec5a5d0e811f1f"
},
"require": {
"ext-soap": "*",
@@ -2744,7 +2744,7 @@
"MIT"
],
"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",
@@ -3655,16 +3655,16 @@
},
{
"name": "symfony/cache",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/cache.git",
"reference": "5d3fcada5e1b80157cfdfd1f9dbbd63f95ef6f13"
"reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/cache/zipball/5d3fcada5e1b80157cfdfd1f9dbbd63f95ef6f13",
"reference": "5d3fcada5e1b80157cfdfd1f9dbbd63f95ef6f13",
"url": "https://api.github.com/repos/symfony/cache/zipball/8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78",
"reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78",
"shasum": ""
},
"require": {
@@ -3731,7 +3731,7 @@
"psr6"
],
"support": {
"source": "https://github.com/symfony/cache/tree/v8.0.4"
"source": "https://github.com/symfony/cache/tree/v8.0.8"
},
"funding": [
{
@@ -3751,7 +3751,7 @@
"type": "tidelift"
}
],
"time": "2026-01-23T12:59:31+00:00"
"time": "2026-03-30T15:18:51+00:00"
},
{
"name": "symfony/cache-contracts",
@@ -3908,16 +3908,16 @@
},
{
"name": "symfony/config",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/config.git",
"reference": "8f45af92f08f82902827a8b6f403aaf49d893539"
"reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/config/zipball/8f45af92f08f82902827a8b6f403aaf49d893539",
"reference": "8f45af92f08f82902827a8b6f403aaf49d893539",
"url": "https://api.github.com/repos/symfony/config/zipball/c7369cc1da250fcbfe0c5a9d109e419661549c39",
"reference": "c7369cc1da250fcbfe0c5a9d109e419661549c39",
"shasum": ""
},
"require": {
@@ -3962,7 +3962,7 @@
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/config/tree/v8.0.4"
"source": "https://github.com/symfony/config/tree/v8.0.8"
},
"funding": [
{
@@ -3982,7 +3982,7 @@
"type": "tidelift"
}
],
"time": "2026-01-13T13:06:50+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/console",
@@ -4076,16 +4076,16 @@
},
{
"name": "symfony/dependency-injection",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/dependency-injection.git",
"reference": "59c3cf87a7ed9beb561cf7433a6635b000a0ff4d"
"reference": "3ce58b0fa844dc647ca1d66ea34748af985728c5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/59c3cf87a7ed9beb561cf7433a6635b000a0ff4d",
"reference": "59c3cf87a7ed9beb561cf7433a6635b000a0ff4d",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/3ce58b0fa844dc647ca1d66ea34748af985728c5",
"reference": "3ce58b0fa844dc647ca1d66ea34748af985728c5",
"shasum": ""
},
"require": {
@@ -4133,7 +4133,7 @@
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dependency-injection/tree/v8.0.4"
"source": "https://github.com/symfony/dependency-injection/tree/v8.0.8"
},
"funding": [
{
@@ -4153,7 +4153,7 @@
"type": "tidelift"
}
],
"time": "2026-01-23T12:59:31+00:00"
"time": "2026-03-31T07:15:36+00:00"
},
{
"name": "symfony/deprecation-contracts",
@@ -4400,16 +4400,16 @@
},
{
"name": "symfony/error-handler",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/error-handler.git",
"reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a"
"reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/7620b97ec0ab1d2d6c7fb737aa55da411bea776a",
"reference": "7620b97ec0ab1d2d6c7fb737aa55da411bea776a",
"url": "https://api.github.com/repos/symfony/error-handler/zipball/c1119fe8dcfc3825ec74ec061b96ef0c8f281517",
"reference": "c1119fe8dcfc3825ec74ec061b96ef0c8f281517",
"shasum": ""
},
"require": {
@@ -4457,7 +4457,7 @@
"description": "Provides tools to manage errors and ease debugging PHP code",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/error-handler/tree/v8.0.4"
"source": "https://github.com/symfony/error-handler/tree/v8.0.8"
},
"funding": [
{
@@ -4477,20 +4477,20 @@
"type": "tidelift"
}
],
"time": "2026-01-23T11:07:10+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/event-dispatcher",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher.git",
"reference": "99301401da182b6cfaa4700dbe9987bb75474b47"
"reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47",
"reference": "99301401da182b6cfaa4700dbe9987bb75474b47",
"url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6",
"reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6",
"shasum": ""
},
"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",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4"
"source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8"
},
"funding": [
{
@@ -4562,7 +4562,7 @@
"type": "tidelift"
}
],
"time": "2026-01-05T11:45:55+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/event-dispatcher-contracts",
@@ -4709,16 +4709,16 @@
},
{
"name": "symfony/filesystem",
"version": "v8.0.1",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "d937d400b980523dc9ee946bb69972b5e619058d"
"reference": "66b769ae743ce2d13e435528fbef4af03d623e5a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/d937d400b980523dc9ee946bb69972b5e619058d",
"reference": "d937d400b980523dc9ee946bb69972b5e619058d",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a",
"reference": "66b769ae743ce2d13e435528fbef4af03d623e5a",
"shasum": ""
},
"require": {
@@ -4755,7 +4755,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v8.0.1"
"source": "https://github.com/symfony/filesystem/tree/v8.0.8"
},
"funding": [
{
@@ -4775,7 +4775,7 @@
"type": "tidelift"
}
],
"time": "2025-12-01T09:13:36+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/finder",
@@ -5234,16 +5234,16 @@
},
{
"name": "symfony/http-foundation",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "e33ba71e674a1bb16eb251688bd27c2ff67e0dc1"
"reference": "02656f7ebeae5c155d659e946f6b3a33df24051b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/e33ba71e674a1bb16eb251688bd27c2ff67e0dc1",
"reference": "e33ba71e674a1bb16eb251688bd27c2ff67e0dc1",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/02656f7ebeae5c155d659e946f6b3a33df24051b",
"reference": "02656f7ebeae5c155d659e946f6b3a33df24051b",
"shasum": ""
},
"require": {
@@ -5290,7 +5290,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v8.0.4"
"source": "https://github.com/symfony/http-foundation/tree/v8.0.8"
},
"funding": [
{
@@ -5310,20 +5310,20 @@
"type": "tidelift"
}
],
"time": "2026-01-09T12:15:10+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "3e61425b663a995957217d03c444b9d27ca7d928"
"reference": "1770f6818d83b2fddc12185025b93f39a90cb628"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/3e61425b663a995957217d03c444b9d27ca7d928",
"reference": "3e61425b663a995957217d03c444b9d27ca7d928",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/1770f6818d83b2fddc12185025b93f39a90cb628",
"reference": "1770f6818d83b2fddc12185025b93f39a90cb628",
"shasum": ""
},
"require": {
@@ -5394,7 +5394,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v8.0.4"
"source": "https://github.com/symfony/http-kernel/tree/v8.0.8"
},
"funding": [
{
@@ -5414,7 +5414,7 @@
"type": "tidelift"
}
],
"time": "2026-01-25T08:21:00+00:00"
"time": "2026-03-31T21:14:05+00:00"
},
{
"name": "symfony/monolog-bridge",
@@ -5574,16 +5574,16 @@
},
{
"name": "symfony/options-resolver",
"version": "v8.0.0",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7"
"reference": "b48bce0a70b914f6953dafbd10474df232ed4de8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8",
"reference": "b48bce0a70b914f6953dafbd10474df232ed4de8",
"shasum": ""
},
"require": {
@@ -5621,7 +5621,7 @@
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.0.0"
"source": "https://github.com/symfony/options-resolver/tree/v8.0.8"
},
"funding": [
{
@@ -5641,7 +5641,7 @@
"type": "tidelift"
}
],
"time": "2025-11-12T15:55:31+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/password-hasher",
@@ -5885,16 +5885,16 @@
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.33.0",
"version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
"reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
"url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
"reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
"shasum": ""
},
"require": {
@@ -5946,7 +5946,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
"source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0"
},
"funding": [
{
@@ -5966,20 +5966,20 @@
"type": "tidelift"
}
],
"time": "2024-12-23T08:48:59+00:00"
"time": "2026-04-10T17:25:58+00:00"
},
{
"name": "symfony/polyfill-php85",
"version": "v1.33.0",
"version": "v1.36.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php85.git",
"reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91"
"reference": "2c408a6bb0313e6001a83628dc5506100474254e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
"reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91",
"url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/2c408a6bb0313e6001a83628dc5506100474254e",
"reference": "2c408a6bb0313e6001a83628dc5506100474254e",
"shasum": ""
},
"require": {
@@ -6026,7 +6026,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0"
"source": "https://github.com/symfony/polyfill-php85/tree/v1.36.0"
},
"funding": [
{
@@ -6046,7 +6046,7 @@
"type": "tidelift"
}
],
"time": "2025-06-23T16:12:55+00:00"
"time": "2026-04-10T16:50:15+00:00"
},
{
"name": "symfony/polyfill-uuid",
@@ -7667,16 +7667,16 @@
},
{
"name": "symfony/var-dumper",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-dumper.git",
"reference": "326e0406fc315eca57ef5740fa4a280b7a068c82"
"reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/326e0406fc315eca57ef5740fa4a280b7a068c82",
"reference": "326e0406fc315eca57ef5740fa4a280b7a068c82",
"url": "https://api.github.com/repos/symfony/var-dumper/zipball/cfb7badd53bf4177f6e9416cfbbccc13c0e773a1",
"reference": "cfb7badd53bf4177f6e9416cfbbccc13c0e773a1",
"shasum": ""
},
"require": {
@@ -7730,7 +7730,7 @@
"dump"
],
"support": {
"source": "https://github.com/symfony/var-dumper/tree/v8.0.4"
"source": "https://github.com/symfony/var-dumper/tree/v8.0.8"
},
"funding": [
{
@@ -7750,20 +7750,20 @@
"type": "tidelift"
}
],
"time": "2026-01-01T23:07:29+00:00"
"time": "2026-03-31T07:15:36+00:00"
},
{
"name": "symfony/var-exporter",
"version": "v8.0.0",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/var-exporter.git",
"reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04"
"reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04",
"reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04",
"url": "https://api.github.com/repos/symfony/var-exporter/zipball/15776bb07a91b089037da89f8832fa41d5fa6ec6",
"reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6",
"shasum": ""
},
"require": {
@@ -7810,7 +7810,7 @@
"serialize"
],
"support": {
"source": "https://github.com/symfony/var-exporter/tree/v8.0.0"
"source": "https://github.com/symfony/var-exporter/tree/v8.0.8"
},
"funding": [
{
@@ -7830,7 +7830,7 @@
"type": "tidelift"
}
],
"time": "2025-11-05T18:53:00+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/web-link",
@@ -11457,16 +11457,16 @@
},
{
"name": "symfony/process",
"version": "v8.0.4",
"version": "v8.0.8",
"source": {
"type": "git",
"url": "https://github.com/symfony/process.git",
"reference": "10df72602d88c0a3fa685b822976a052611dd607"
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/process/zipball/10df72602d88c0a3fa685b822976a052611dd607",
"reference": "10df72602d88c0a3fa685b822976a052611dd607",
"url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc",
"shasum": ""
},
"require": {
@@ -11498,7 +11498,7 @@
"description": "Executes commands in sub-processes",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/process/tree/v8.0.4"
"source": "https://github.com/symfony/process/tree/v8.0.8"
},
"funding": [
{
@@ -11518,7 +11518,7 @@
"type": "tidelift"
}
],
"time": "2026-01-23T11:07:10+00:00"
"time": "2026-03-30T15:14:47+00:00"
},
{
"name": "symfony/web-profiler-bundle",

View File

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

View File

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

View File

@@ -210,18 +210,18 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* initial_marking?: list<scalar|Param|null>,
* events_to_dispatch?: list<string|Param>|null,
* places?: list<array{ // Default: []
* name: scalar|Param|null,
* name?: scalar|Param|null,
* metadata?: list<mixed>,
* }>,
* transitions: list<array{ // Default: []
* name: string|Param,
* transitions?: list<array{ // Default: []
* name?: string|Param,
* guard?: string|Param, // An expression to block the transition.
* from?: list<array{ // Default: []
* place: string|Param,
* place?: string|Param,
* weight?: int|Param, // Default: 1
* }>,
* to?: list<array{ // Default: []
* place: string|Param,
* place?: string|Param,
* 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
* enabled?: bool|Param, // Default: false
* resource: scalar|Param|null,
* resource?: 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
* 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
* },
* middleware?: list<string|array{ // Default: []
* id: scalar|Param|null,
* id?: scalar|Param|null,
* 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"
* 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
* 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>,
* 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).
@@ -674,7 +674,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* enabled?: bool|Param, // Default: false
* message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus"
* routing?: array<string, array{ // Default: []
* service: scalar|Param|null,
* service?: scalar|Param|null,
* secret?: scalar|Param|null, // Default: ""
* }>,
* },
@@ -754,8 +754,8 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* },
* ldap?: array{
* service: scalar|Param|null,
* base_dn: scalar|Param|null,
* service?: scalar|Param|null,
* base_dn?: scalar|Param|null,
* search_dn?: scalar|Param|null, // Default: null
* search_password?: scalar|Param|null, // Default: 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
* },
* 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
* 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"
* },
* }>,
* firewalls: array<string, array{ // Default: []
* firewalls?: array<string, array{ // Default: []
* pattern?: scalar|Param|null,
* host?: 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"
* },
* 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
* 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
* 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.
@@ -940,13 +940,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* failure_handler?: scalar|Param|null,
* realm?: scalar|Param|null, // Default: null
* token_extractors?: list<scalar|Param|null>,
* token_handler: string|array{
* token_handler?: string|array{
* id?: scalar|Param|null,
* 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.
* 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"
@@ -954,25 +954,25 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* },
* oidc?: array{
* discovery?: array{ // Enable the OIDC discovery.
* base_uri: list<scalar|Param|null>,
* base_uri?: list<scalar|Param|null>,
* 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"
* audience: scalar|Param|null, // Audience set in the token, for validation purpose.
* issuers: list<scalar|Param|null>,
* algorithms: list<scalar|Param|null>,
* audience?: scalar|Param|null, // Audience set in the token, for validation purpose.
* issuers?: 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).
* encryption?: bool|array{
* enabled?: bool|Param, // Default: false
* enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false
* 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).
* 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).
* },
* },
* 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"
* http_client?: scalar|Param|null, // HTTP Client service // Default: null
* },
@@ -1036,7 +1036,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* dbal?: array{
* default_connection?: scalar|Param|null,
* types?: array<string, string|array{ // Default: []
* class: scalar|Param|null,
* class?: scalar|Param|null,
* }>,
* driver_schemes?: array<string, scalar|Param|null>,
* connections?: array<string, array{ // Default: []
@@ -1207,7 +1207,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* datetime_functions?: array<string, scalar|Param|null>,
* },
* filters?: array<string, string|array{ // Default: []
* class: scalar|Param|null,
* class?: scalar|Param|null,
* enabled?: bool|Param, // Default: false
* parameters?: array<string, mixed>,
* }>,
@@ -1320,14 +1320,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* access_token_issuance?: bool|array{
* enabled?: bool|Param, // Default: false
* signature?: array{
* algorithm: scalar|Param|null, // The algorithm use to sign the access tokens.
* key: scalar|Param|null, // The signature key. It shall be JWK encoded.
* algorithm?: scalar|Param|null, // The algorithm use to sign the access tokens.
* key?: scalar|Param|null, // The signature key. It shall be JWK encoded.
* },
* encryption?: bool|array{
* enabled?: bool|Param, // Default: false
* 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.
* key: scalar|Param|null, // The encryption key. It shall be JWK encoded.
* 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.
* key?: scalar|Param|null, // The encryption key. It shall be JWK encoded.
* },
* },
* access_token_verification?: bool|array{
@@ -1337,7 +1337,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* claim_checkers?: list<scalar|Param|null>,
* mandatory_claims?: 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{
* enabled?: bool|Param, // Default: false
@@ -1345,7 +1345,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* header_checkers?: list<scalar|Param|null>,
* allowed_key_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{
@@ -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
* tags?: list<array{ // Default: []
* name: scalar|Param|null,
* name?: scalar|Param|null,
* description?: scalar|Param|null, // Default: null
* }>,
* license?: array{
@@ -1605,14 +1605,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* name?: mixed,
* allow_create?: mixed,
* item_uri_template?: mixed,
* ...<mixed>
* ...<string, mixed>
* },
* }
* @psalm-type MonologConfig = array{
* use_microseconds?: scalar|Param|null, // Default: true
* channels?: list<scalar|Param|null>,
* handlers?: array<string, array{ // Default: []
* type: scalar|Param|null,
* type?: scalar|Param|null,
* id?: scalar|Param|null,
* enabled?: bool|Param, // Default: true
* priority?: scalar|Param|null, // Default: 0
@@ -1735,7 +1735,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* headers?: list<scalar|Param|null>,
* mailer?: scalar|Param|null, // Default: null
* email_prototype?: string|array{
* id: scalar|Param|null,
* id?: scalar|Param|null,
* method?: scalar|Param|null, // Default: null
* },
* verbosity_levels?: array{
@@ -1752,14 +1752,14 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* }>,
* }
* @psalm-type EdnotifConfig = array{
* guichet_wsdl: scalar|Param|null,
* metier_wsdl: scalar|Param|null,
* exploitation_code: 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, // Default: "/var/www/html/vendor/malio/ednotif-bundle/resources/ednotif-ws/wsIpBNotif.wsdl"
* exploitation_code?: scalar|Param|null,
* zone?: scalar|Param|null, // Default: null
* application?: scalar|Param|null, // Default: null
* login: scalar|Param|null,
* password: scalar|Param|null,
* exploitation_number: scalar|Param|null,
* login?: scalar|Param|null,
* password?: scalar|Param|null,
* exploitation_number?: scalar|Param|null,
* exploitation_country_code?: scalar|Param|null, // Default: "FR"
* token_ttl_seconds?: int|Param, // Default: 900
* soap_options?: array{

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.76'
app.version: '0.0.86'

View File

@@ -1,5 +1,5 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Réception</h1>
<UiSelect
@@ -141,6 +141,7 @@ const router = useRouter()
const receptionStore = useReceptionStore()
const isHydrating = ref(false)
const submitted = ref(false)
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<ReceptionFormData>({
licensePlate: '',
@@ -217,7 +218,7 @@ onMounted(async () => {
await loadVehicles()
})
async function validate() {
const buildPayload = () => {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim()
const normalizedReceptionTypeId = form.receptionTypeId.trim()
@@ -236,7 +237,7 @@ async function validate() {
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
const basePayload = {
return {
licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate,
receptionType: receptionTypeIri,
@@ -244,13 +245,35 @@ async function validate() {
supplier: supplierIri,
address: addressIri,
truck: truckIri,
carrier: carrierIri
}
const payload = {
...basePayload,
carrier: carrierIri,
...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {})
}
}
const saveDraft = async () => {
const payload = buildPayload()
if (!receptionStore.current) {
await receptionStore.createReception({
currentStep: 0,
...payload
})
return
}
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: receptionStore.current.currentStep,
...payload
})
}
const validateFields = () => {
submitted.value = true
return formRef.value?.reportValidity() ?? false
}
defineExpose({ saveDraft, validateFields })
async function validate() {
const payload = buildPayload()
if (!receptionStore.current) {
const created = await receptionStore.createReception({

View File

@@ -1,5 +1,5 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
<UiSelect
@@ -152,6 +152,7 @@ const router = useRouter()
const shipmentStore = useShipmentStore()
const isHydrating = ref(false)
const submitted = ref(false)
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<ShipmentFormData>({
userId: '',
@@ -286,7 +287,12 @@ const saveDraft = async () => {
})
}
defineExpose({ saveDraft })
const validateFields = () => {
submitted.value = true
return formRef.value?.reportValidity() ?? false
}
defineExpose({ saveDraft, validateFields })
const validate = async () => {
const payload = buildPayload()

View File

@@ -0,0 +1,233 @@
<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' : ''"
: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 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
:disabled="currentPage <= 1"
aria-label="Page précédente"
@click="goToPage(currentPage - 1)"
>
Prev
</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 px-3 text-sm text-primary-500 hover:underline disabled:cursor-not-allowed disabled:text-slate-400 disabled:no-underline"
:disabled="currentPage >= totalPages"
aria-label="Page suivante"
@click="goToPage(currentPage + 1)"
>
Next
</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
}>(), {
totalItems: undefined,
page: 1,
perPage: 10,
perPageOptions: () => [10, 25, 50],
rowClickable: false,
showActions: false,
emptyMessage: 'Aucune donnée',
loading: false
})
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 ?? ''"
:disabled="disabled"
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="[
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass
@@ -36,12 +37,14 @@ const props = withDefaults(
label?: string
modelValue: string | null | undefined
disabled?: boolean
size?: 'default' | 'compact'
wrapperClass?: string
labelClass?: string
inputClass?: string
}>(),
{
disabled: false,
size: 'default',
wrapperClass: '',
labelClass: '',
inputClass: ''
@@ -54,6 +57,11 @@ const emit = defineEmits<{
const attrs = useAttrs()
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 target = event.target as HTMLInputElement

View File

@@ -13,15 +13,16 @@
:value="modelValue ?? ''"
:disabled="disabled || loading"
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="[
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
selectClass
]"
@change="onChange"
>
<option value="" disabled class="text-neutral-400">
<option value="" class="text-neutral-400">
{{ placeholderText }}
</option>
<option
@@ -55,6 +56,7 @@ const props = withDefaults(
options: SelectOption[]
disabled?: boolean
loading?: boolean
size?: 'default' | 'compact'
wrapperClass?: string
labelClass?: string
selectClass?: string
@@ -63,6 +65,7 @@ const props = withDefaults(
placeholder: 'Sélectionner',
disabled: false,
loading: false,
size: 'default',
wrapperClass: '',
labelClass: '',
selectClass: ''
@@ -77,6 +80,11 @@ const attrs = useAttrs()
const isEmpty = computed(() => props.modelValue === '' || props.modelValue === null || props.modelValue === undefined)
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 target = event.target as HTMLSelectElement

View File

@@ -16,9 +16,10 @@
:maxlength="maxlength"
:disabled="disabled"
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="[
isEmpty ? 'text-neutral-400' : 'text-black',
sizeClass,
isEmpty ? 'text-neutral-400' : 'text-primary-700',
disabled ? 'cursor-not-allowed' : 'cursor-text',
inputClass
]"
@@ -40,6 +41,7 @@ const props = withDefaults(
placeholder?: string
maxlength?: number | string
disabled?: boolean
size?: 'default' | 'compact'
wrapperClass?: string
labelClass?: string
inputClass?: string
@@ -48,6 +50,7 @@ const props = withDefaults(
placeholder: '',
maxlength: undefined,
disabled: false,
size: 'default',
wrapperClass: '',
labelClass: '',
inputClass: ''
@@ -60,6 +63,11 @@ const emit = defineEmits<{
const attrs = useAttrs()
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 target = event.target as HTMLInputElement

View File

@@ -10,7 +10,7 @@
:maxlength="maxLength"
:placeholder="placeholderText"
: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"
/>
<UiCheckbox

View File

@@ -60,6 +60,7 @@ const {
title,
fetchWeight,
saveWeight,
saveWeightDraft,
showGenerateReceipt,
printReceipt
} = useWeighingStep({
@@ -75,4 +76,6 @@ const {
clearEntity: props.clearEntity,
buildReceiptFilename: props.buildReceiptFilename
})
defineExpose({ saveWeightDraft })
</script>

View File

@@ -29,13 +29,15 @@ export const useWeighingStep = (options: UseWeighingStepOptions) => {
title,
showLoadingBox,
fetchWeight,
saveWeight
saveWeight,
saveWeightDraft
} = useWeighing({
mode: options.mode,
entity: options.entity,
entityName: options.entityName,
apiResource: options.apiResource,
titleLabel: options.titleLabel,
isFinal: options.isFinal,
getWeightFromScale: options.getWeightFromScale,
updateEntity: options.updateEntity,
loadEntity: options.loadEntity
@@ -71,6 +73,7 @@ export const useWeighingStep = (options: UseWeighingStepOptions) => {
showLoadingBox,
fetchWeight,
saveWeight,
saveWeightDraft,
showGenerateReceipt,
printReceipt
}

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'
apiResource: string
titleLabel: string
isFinal?: boolean
getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any>
loadEntity?: (id: number) => Promise<any>
@@ -23,6 +24,7 @@ export const useWeighing = ({
entityName,
apiResource,
titleLabel,
isFinal = false,
getWeightFromScale,
updateEntity,
loadEntity
@@ -77,7 +79,7 @@ export const useWeighing = ({
})
}
const nextStep = mode === 'tare'
const nextStep = isFinal
? entity.value.currentStep
: entity.value.currentStep + 1
await updateEntity(entity.value.id, {
@@ -90,6 +92,38 @@ export const useWeighing = ({
}
}
const saveWeightDraft = async () => {
if (!entity.value) return
if (!weightData.value && !currentWeightEntry.value) return
const existingEntry = currentWeightEntry.value
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
if (baseWeight === null) return
const relationPayload: Record<string, string> = {}
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
if (existingEntry?.id) {
await updateWeight(existingEntry.id, {
type: mode,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
} else {
await createWeight({
...relationPayload,
type: mode,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
}
}
return {
weightData,
currentWeightEntry,
@@ -98,7 +132,8 @@ export const useWeighing = ({
title,
showLoadingBox,
fetchWeight,
saveWeight
saveWeight,
saveWeightDraft
}
}
@@ -119,7 +154,7 @@ export const useWeighingShipment = ({
entity: shipment,
entityName: 'shipment',
apiResource: 'shipments',
titleLabel: modeShipment === 'gross' ? 'Pesée à vide' : 'Pesée à plein',
titleLabel: modeShipment === 'gross' ? 'Pesée à plein' : 'Pesée à vide',
getWeightFromScale: async () => {
const { getWeightShipment } = await import('~/services/shipment')
return getWeightShipment()

View File

@@ -48,7 +48,8 @@ export const useWorkflowSteps = (config: WorkflowConfig, store: WorkflowStore) =
return
}
const datePayload: Record<string, any> = {}
datePayload[config.dateField] = store.current[config.dateField]
const rawDate = store.current[config.dateField]
datePayload[config.dateField] = rawDate ? rawDate.slice(0, 10) : rawDate
await store[updateMethod](store.current.id, {
currentStep: store.current.currentStep,
licensePlate: store.current.licensePlate,

View File

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

View File

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

View File

@@ -122,6 +122,23 @@
Bovins
</a>
</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>
<!-- 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">
Bovins
</NuxtLink>
<NuxtLink to="/scan" @click="closeMenu">
Scanner
</NuxtLink>
</nav>
<button
@@ -231,7 +251,7 @@
</aside>
</transition>
</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/>
</main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">

View File

@@ -1,33 +1,31 @@
<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>
</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>Nom</div>
<div>Code</div>
</div>
<div v-if="!auth.isAdmin" class="px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
<div v-else-if="bovinList.length === 0" class="px-4 py-6 text-slate-400">
Aucun type de bovin.
</div>
<template v-else>
<div
v-for="bovin in bovinList"
:key="bovin.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="goToBovin(bovin.id)"
@keydown.enter="goToBovin(bovin.id)"
>
<div>{{ bovin.label }}</div>
<div>{{ bovin.code }}</div>
</div>
</template>
<div v-if="auth.isAdmin" 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="goToBovin"
>
<template #header-label>
<UiTextInput v-model="filters.label" placeholder="Nom" size="compact" />
</template>
<template #header-code>
<UiTextInput v-model="filters.code" placeholder="Code" size="compact" />
</template>
</UiDataTable>
</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">
<NuxtLink
to="/admin/bovin"
@@ -35,24 +33,37 @@
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
@click="handleAddClick"
>
<Icon name="mdi:plus" size="28" />
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import { getBovineTypeList } from "~/services/bovine-type"
import type { BovineTypeData } from "~/services/dto/bovine-type-data"
import { useAuthStore } from "~/stores/auth"
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const bovinList = ref<BovineTypeData[]>([])
const router = useRouter()
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
router.push(`/admin/bovin/${id}`)
router.push(`/admin/bovin/${bovin.id}`)
}
const handleAddClick = (event: Event) => {
@@ -60,8 +71,7 @@ const handleAddClick = (event: Event) => {
event.preventDefault()
}
onMounted(async () => {
if (!auth.isAdmin) return
bovinList.value = await getBovineTypeList()
onMounted(() => {
if (auth.isAdmin) reload()
})
</script>

View File

@@ -1,51 +1,62 @@
<template>
<div class="flex items-center justify-between ">
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
<div class="flex items-center justify-between">
<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 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">
<NuxtLink
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"
>
<Icon name="mdi:plus" size="28" />
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
</template>
<script setup lang="ts">
import type {CarrierData} from "~/services/dto/carrier-data";
import {getCarrierList} from "~/services/carrier";
import type { CarrierData } from '~/services/dto/carrier-data'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const carrierList = ref<CarrierData[]>()
const router = useRouter()
const goToCarrier = (id: number) => {
router.push(`/admin/carrier/${id}`)
const { items, totalItems, page, perPage, filters, loading, reload } =
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 () => {
carrierList.value = await getCarrierList(false)
})
onMounted(reload)
</script>

View File

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

View File

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

View File

@@ -41,10 +41,24 @@
type="password"
:disabled="!auth.isAdmin"
wrapper-class="w-[280px]"
required
:required="!userId"
/>
</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">
<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"
@@ -86,7 +100,8 @@ const resolveUserId = (param: unknown) => {
const form = reactive<UserFormData>({
username: '',
password: '',
role: ''
role: '',
isLocked: false
})
const hydrateFromUser = (user: UserData | null) => {
@@ -99,6 +114,7 @@ const hydrateFromUser = (user: UserData | null) => {
const hasAdmin = roles.includes('ROLE_ADMIN')
form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER'
form.password = ''
form.isLocked = user.isLocked ?? false
isHydrating.value = false
}
@@ -129,6 +145,7 @@ async function validate() {
const basePayload: UserPayload = {
username: normalizedUsername,
roles: normalizedRole ? [normalizedRole] : undefined,
isLocked: form.isLocked,
}
if (normalizedPassword) {
basePayload.password = normalizedPassword

View File

@@ -3,31 +3,52 @@
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
</div>
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 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">
<div>Utilisateur</div>
<div>Role</div>
</div>
<div v-if="userList.length === 0" class="px-4 py-6 text-slate-400">
Aucun utilisateur.
</div>
<template v-else>
<div
v-for="user in userList"
:key="user.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 items-center"
role="button"
tabindex="0"
@click="goToUser(user.id)"
@keydown.enter="goToUser(user.id)"
>
<div>{{ user.username }}</div>
<div>{{ getRoleLabels(user.roles) }}</div>
</div>
</template>
<div v-if="auth.isAdmin" 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="goToUser"
>
<template #header-username>
<UiTextInput
v-model="filters.username"
placeholder="Utilisateur"
size="compact"
/>
</template>
<template #header-roles>
<UiTextInput :model-value="''" placeholder="Role" size="compact" disabled />
</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 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 class="flex justify-center items-center">
@@ -44,19 +65,43 @@
</template>
<script setup lang="ts">
import type { UserData } from "~/services/dto/user-data"
import { getAdminUsers } from "~/services/auth"
import { ROLE } from "~/utils/constants"
import { useAuthStore } from "~/stores/auth"
import type { UserData } from '~/services/dto/user-data'
import { ROLE } from '~/utils/constants'
import { useAuthStore } from '~/stores/auth'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const userList = ref<UserData[]>([])
const router = useRouter()
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
router.push(`/admin/user/${id}`)
router.push(`/admin/user/${user.id}`)
}
const handleAddClick = (event: Event) => {
@@ -64,18 +109,7 @@ const handleAddClick = (event: Event) => {
event.preventDefault()
}
const getRoleLabels = (roles?: string[]) => {
if (!roles || roles.length === 0) {
return '---'
}
return roles
.map((role) => roleLabelByValue.get(role) ?? role)
.join(', ')
}
onMounted(async () => {
if (!auth.isAdmin) return
userList.value = await getAdminUsers()
onMounted(() => {
if (auth.isAdmin) reload()
})
</script>

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"
: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="[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]"
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
:title="cell.caseStatusLabel ?? undefined"
@@ -58,25 +58,19 @@
<!-- Légende : survol d'un statut => atténue les autres cases -->
<div class="py-4">
<!-- 3 zones fixes pour forcer gauche / centre / droite sur toute la largeur -->
<div class="grid w-full grid-cols-3 gap-3">
<div class="flex gap-6">
<div
v-for="(statut, index) in statutLegend"
:key="statut.id"
class="flex min-w-0 cursor-pointer items-center gap-2 py-1"
:class="[
index === 0 ? 'justify-self-start' : '',
index === statutLegend.length - 1 ? 'justify-self-end' : '',
index > 0 && index < statutLegend.length - 1 ? 'justify-self-center' : ''
]"
@mouseenter="activeLegendStatutId = statut.id"
@mouseleave="activeLegendStatutId = null"
v-for="statut in statutLegend"
:key="statut.label"
class="flex cursor-pointer items-center gap-2 py-1"
@mouseenter="activeLegendLabel = statut.label"
@mouseleave="activeLegendLabel = null"
>
<span
class="h-5 w-5 border border-slate-300"
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
></span>
<span class="truncate text-sm uppercase text-slate-700">
<span class="text-sm uppercase text-slate-700">
{{ statut.label }}
</span>
</div>
@@ -90,33 +84,35 @@
import type {BuildingData} from "~/services/dto/building-data"
import type {BuildingLayoutData} from "~/services/dto/building-layout-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 {getStatutList} from "~/services/statut"
definePageMeta({layout: "default"})
const router = useRouter()
// Données brutes chargées depuis l'API
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)
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)
const buildingLayouts = computed(() =>
buildingList.value.map((building) => {
// On affiche uniquement le premier layout du bâtiment
const layout = building.layouts?.[0] ?? null
const view = layout ? buildLayoutView(layout) : null
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
})
buildingList.value
.filter((building) => building.layouts && building.layouts.length > 0)
.map((building) => {
const layout = building.layouts![0]
const view = buildLayoutView(layout)
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
})
)
type GridCell = {
key: string
caseId: number | null
display: string
caseStatusId: number | null
caseStatusLabel: string | null
// Couleur de fond de la case (dépend du statut)
caseStyle?: Record<string, string>
@@ -130,7 +126,8 @@ type GridCell = {
contentInsetClass: string
}
// 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
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
const caseId = (position.buildingCase?.id ?? 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 statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
@@ -191,7 +187,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
columnSpan,
caseId,
display: caseNumber !== null ? String(caseNumber) : "Case",
caseStatusId,
caseStatusLabel,
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
@@ -230,13 +225,6 @@ const buildLayoutView = (layout: BuildingLayoutData): {
}
onMounted(async () => {
// Chargement initial des bâtiments et de la légende des statuts
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"})
)
buildingList.value = await getBuildingList()
})
</script>

View File

@@ -1,27 +1,186 @@
<template>
<div class="flex justify-center items-center">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!hasCaseId"
@click="printCaseReport"
>
Imprimer
</UiButton>
<div class="px-[86px]">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/infrastructure/building')"
name="gg:arrow-left-o"
size="44"
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"
: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"
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60 pointer-events-none'"
>
<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>
<UiDateInput v-model="arrivalDateFilter" size="compact" />
</template>
<template #cell-arrivalDate="{ item }">
{{ formatDate(item.arrivalDate) }}
</template>
<template #cell-receivedWeight="{ item }">
{{ item.receivedWeight ?? '—' }}
</template>
</UiDataTable>
</div>
</div>
</template>
<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 router = useRouter()
const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id))
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) {
buildingCase.value = null
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`
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>

View File

@@ -14,9 +14,10 @@
>Mettre en attente
</UiButton>
</div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0" ref="receptionFormRef"/>
<WorkflowWeight
v-if="storeReception?.currentStep === 1"
ref="grossWeightRef"
mode="gross"
entity-name="reception"
api-resource="receptions"
@@ -37,6 +38,7 @@
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
<WorkflowWeight
v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3"
ref="tareWeightRef"
mode="tare"
entity-name="reception"
api-resource="receptions"
@@ -61,8 +63,24 @@ import { RECEPTION_TYPE_CODES } from '~/utils/constants'
const receptionStore = useReceptionStore()
const { current: storeReception } = storeToRefs(receptionStore)
const receptionFormRef = ref<{ saveDraft: () => Promise<void>, validateFields: () => boolean } | null>(null)
const grossWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
const tareWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
const { stepLabels, saveAndHold, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore)
const { stepLabels, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore)
const router = useRouter()
const saveAndHold = async () => {
if (receptionFormRef.value) {
if (!receptionFormRef.value.validateFields()) return
await receptionFormRef.value.saveDraft()
} else {
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
}
await router.push('/')
}
// Init route watcher
const route = useRoute()

View File

@@ -5,41 +5,126 @@
</div>
<div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Numéro</div>
<div>Date et heure</div>
<div>Fournisseur</div>
<div>Adresse</div>
<div>Type réception</div>
<div>Poids</div>
</div>
<div
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 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-clickable
@row-click="goToReception"
>
<div>{{ reception.identificationNumber}}</div>
<div>{{ formatDate(reception.receptionDate) }}</div>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ formatWeighing(reception) }}</div>
</div>
<template #header-identificationNumber>
<UiTextInput
v-model="filters.identificationNumber"
placeholder="Numéro"
size="compact"
/>
</template>
<template #header-receptionDate>
<UiDateInput
v-model="receptionDateFilter"
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>
</template>
<script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data";
import {getReceptionList} from "~/services/reception";
import type {ShipmentData} from "~/services/dto/shipment-data";
import type { ReceptionData } from '~/services/dto/reception-data'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { getReceptionTypeList } from '~/services/reception-type'
import { useDataTableServerState } from '~/composables/useDataTableServerState'
const receptionList = ref<ReceptionData[]>()
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 et heure', 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) => {
if (!date) return '—'
@@ -65,11 +150,12 @@ const formatWeighing = (reception: ReceptionData) => {
return `${gross - tare} kg`
}
const goToReception = (id: number) => {
router.push(`/reception/update/${id}`)
const goToReception = (reception: ReceptionData) => {
router.push(`/reception/update/${reception.id}`)
}
onMounted(async () => {
receptionList.value = await getReceptionList(true)
receptionTypes.value = await getReceptionTypeList()
reload()
})
</script>

View File

@@ -1,40 +1,135 @@
<template>
<WorkflowWaitingList
title="listes des réceptions en attente"
:columns="columns"
:items="receptionList ?? []"
route-prefix="/reception"
show-actions
>
<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>
</WorkflowWaitingList>
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1>
</div>
<div class="px-[86px]">
<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"
:show-actions="auth.isAdmin"
row-clickable
@row-click="goToReception"
>
<template #header-receptionDate>
<UiDateInput v-model="receptionDateFilter" 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>
<script setup lang="ts">
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 = [
{ key: 'receptionDate', label: 'Date et heure' },
{ key: 'supplier.name', label: 'Fournisseur' },
{ key: 'address.fullAddress', label: 'Adresse' },
{ key: 'receptionType.label', label: 'Type réception' },
{ key: 'receptionDate', label: 'Date et heure', 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: '1.1fr' },
{ key: 'carrier.name', label: 'Transporteur' },
{ key: 'licensePlate', label: 'Immatriculation' }
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
]
const receptionList = ref<ReceptionData[]>()
const formatDate = (date: string | null) => {
if (!date) return '—'
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 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.`
@@ -55,10 +154,11 @@ const confirmDelete = async (reception: ReceptionData) => {
if (!confirmed) return
await deleteReception(reception.id)
receptionList.value = receptionList.value?.filter(r => r.id !== reception.id)
reload()
}
onMounted(async () => {
receptionList.value = await getReceptionList(false)
receptionTypes.value = await getReceptionTypeList()
reload()
})
</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,10 +18,11 @@
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<WorkflowWeight
v-if="storeShipment?.currentStep === 1"
mode="gross"
ref="tareWeightRef"
mode="tare"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.gross"
:title-label="shipmentConfig.weighingLabels.tare"
:is-final="false"
:entity="storeShipment"
:get-weight-from-scale="getWeightShipment"
@@ -33,10 +34,11 @@
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<WorkflowWeight
v-if="storeShipment?.currentStep === 3"
mode="tare"
ref="grossWeightRef"
mode="gross"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.tare"
:title-label="shipmentConfig.weighingLabels.gross"
:is-final="true"
:entity="storeShipment"
:get-weight-from-scale="getWeightShipment"
@@ -58,7 +60,9 @@ import { ref, watch } from 'vue'
const shipmentStore = useShipmentStore()
const { current: storeShipment } = storeToRefs(shipmentStore)
const shipmentFormRef = ref<{ saveDraft: () => Promise<void> } | null>(null)
const shipmentFormRef = ref<{ saveDraft: () => Promise<void>, validateFields: () => boolean } | null>(null)
const grossWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
const tareWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
const { stepLabels, handleStepSelect } = useWorkflowSteps(shipmentConfig, shipmentStore)
@@ -83,7 +87,11 @@ watch(
const saveAndHold = async () => {
if (shipmentFormRef.value) {
if (!shipmentFormRef.value.validateFields()) return
await shipmentFormRef.value.saveDraft()
} else {
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
}
await router.push('/')
}

View File

@@ -5,51 +5,148 @@
</div>
<div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Numéro</div>
<div>Date</div>
<div>Client</div>
<div>Adresse</div>
<div>Type d'expéditon</div>
<div>Poids</div>
</div>
<div
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 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-clickable
@row-click="goToShipment"
>
<div>{{ shipment.identificationNumber }}</div>
<div>{{ shipment.shipmentDate }}</div>
<div>{{ shipment.customer?.name }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatShipmentLines(shipment).length">
<template #header-identificationNumber>
<UiTextInput
v-model="filters.identificationNumber"
placeholder="Numéro"
size="compact"
/>
</template>
<template #header-shipmentDate>
<UiDateInput v-model="shipmentDateFilter" 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
v-for="(line, index) in formatShipmentLines(shipment)"
v-for="(line, index) in formatShipmentLines(item)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
</div>
<div>{{ formatWeighing(shipment) }}</div>
</div>
<template v-else></template>
</template>
<template #cell-weighing="{ item }">
{{ formatWeighing(item) }}
</template>
</UiDataTable>
</div>
</div>
</template>
<script setup lang="ts">
import type {ShipmentData} from "~/services/dto/shipment-data";
import {getShipmentList} from "~/services/shipment";
import type { ShipmentData } from '~/services/dto/shipment-data'
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 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 gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
@@ -62,24 +159,12 @@ const formatWeighing = (shipment: ShipmentData) => {
return `${gross - tare} kg`
}
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 goShipment = (id: number) => {
router.push(`/shipment/update/${id}`)
const goToShipment = (shipment: ShipmentData) => {
router.push(`/shipment/update/${shipment.id}`)
}
onMounted(async () => {
shipmentList.value = await getShipmentList(true)
shipmentTypes.value = await getShipmentTypeList()
reload()
})
</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">
<h1
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="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
@@ -167,6 +157,16 @@
>
pesée à vide
</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 class="mb-12">
<update-weight
@@ -248,7 +248,7 @@ const hasTareWeightError = computed(() =>
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 tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const formIsLoading = ref(false)

View File

@@ -1,52 +1,147 @@
<template>
<WorkflowWaitingList
title="listes des expéditions en attente"
:columns="columns"
:items="shipmentList ?? []"
route-prefix="/shipment"
show-actions
>
<template #cell-shipmentDate="{ item }">
{{ formatDate(item.shipmentDate) }}
</template>
<template #cell-shipmentType="{ 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>
</WorkflowWaitingList>
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1>
</div>
<div class="px-[86px]">
<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"
:show-actions="auth.isAdmin"
row-clickable
@row-click="goToShipment"
>
<template #header-shipmentDate>
<UiDateInput v-model="shipmentDateFilter" 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é."
: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>
<script setup lang="ts">
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 = [
{ key: 'shipmentDate', label: 'Date et heure' },
{ key: 'customer.name', label: 'Client' },
{ key: 'address.fullAddress', label: 'Adresse' },
{ key: 'shipmentType', label: "Type d'expé." },
{ key: 'shipmentDate', label: 'Date et heure', width: '120px' },
{ key: 'customer.name', label: 'Client', width: '1.5fr' },
{ key: 'address.fullAddress', label: 'Adresse', width: '2fr' },
{ key: 'shipmentType.label', label: "Type d'expé.", width: '1.1fr' },
{ key: 'carrier.name', label: 'Transporteur' },
{ key: 'licensePlate', label: 'Immatriculation' }
{ key: 'licensePlate', label: 'Immatriculation', width: '110px' }
]
const shipmentList = ref<ShipmentData[]>()
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date.replace(' ', 'T'))
@@ -70,6 +165,10 @@ const formatShipmentLines = (shipment: ShipmentData) => {
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
}
const goToShipment = (shipment: ShipmentData) => {
router.push(`/shipment/${shipment.id}`)
}
const confirmDelete = async (shipment: ShipmentData) => {
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.`
@@ -77,10 +176,11 @@ const confirmDelete = async (shipment: ShipmentData) => {
if (!confirmed) return
await deleteShipment(shipment.id)
shipmentList.value = shipmentList.value?.filter(s => s.id !== shipment.id)
reload()
}
onMounted(async () => {
shipmentList.value = await getShipmentList(false)
shipmentTypes.value = await getShipmentTypeList()
reload()
})
</script>

View File

@@ -66,7 +66,6 @@ export async function logout() {
const api = useApi()
return api.post<void>('logout', {}, {
toastErrorKey: 'errors.auth.logout',
toastSuccessKey: 'success.auth.logout',
redirect: 'manual'
toastSuccessKey: 'success.auth.logout'
})
}

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,16 @@
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
buildingCase: string | null
supplier: 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 {
id: number
caseNumber: number | null
code: string | 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 { BuildingCaseData } from '~/services/dto/building-case-data'
export interface BuildingData {
id: number
label: string
code: string
layouts?: BuildingLayoutData[] | null
buildingCases?: BuildingCaseData[] | null
}

View File

@@ -2,16 +2,19 @@ export interface UserData {
id: number
username: string
roles: string[]
isLocked: boolean
}
export type UserPayload = {
username?: string
password?: string
roles?: string[]
isLocked?: boolean
}
export type UserFormData = {
username: string
password: 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 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 {ROLE} from '~/utils/constants'
@@ -58,7 +58,7 @@ export const useAuthStore = defineStore('auth', {
},
async updateUser(id: number, payload: UserPayload) {
this.isLoading = true
const result = await createUser(payload).finally(() => {
const result = await updateUser(id, payload).finally(() => {
this.isLoading = false
})
return result

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,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\Get;
use App\State\AppVersionProvider;
use App\State\System\AppVersionProvider;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(

View File

@@ -7,12 +7,14 @@ namespace App\ApiResource;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\BovinIdentificationProvider;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\State\Bovin\BovinIdentificationProvider;
#[ApiResource(
operations: [
new Get(
uriTemplate: '/bovins/{numeroNational}/identification',
openapi: new OpenApiOperation(tags: ['Bovins']),
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,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\ReceptionType;
use App\Entity\ShipmentType;
use App\Entity\Statut;
use App\Entity\Supplier;
use App\Entity\Truck;
use App\Entity\Vehicle;
@@ -230,24 +229,6 @@ class SeedCommand extends Command
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);
$layoutByBuildingCode = [];
$layoutRows = [
@@ -274,25 +255,15 @@ class SeedCommand extends Command
}
$caseRows = [
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'status' => 'LB'],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'status' => 'OC'],
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'status' => 'ML'],
['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'],
['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
];
$caseByCode = [];
foreach ($caseRows as $caseRow) {
$building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]);
$status = $statusByCode[$caseRow['status']] ?? null;
if (!$building instanceof Building || !$status instanceof Statut) {
if (!$building instanceof Building) {
continue;
}
@@ -300,13 +271,12 @@ class SeedCommand extends Command
$code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber);
/** @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
->setCode($code)
->setCaseNumber($caseNumber)
->setCapacity(15)
->setIdBuilding($building)
->setStatut($status)
;
});
$caseByCode[$code] = $buildingCase;

View File

@@ -8,7 +8,6 @@ use App\Entity\Building;
use App\Entity\BuildingCase;
use App\Entity\BuildingCasePosition;
use App\Entity\BuildingLayout;
use App\Entity\Statut;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
@@ -18,10 +17,9 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
{
public function load(ObjectManager $manager): void
{
$statuts = $this->loadStatuts($manager);
$buildings = $this->getBuildingsByCode($manager, ['B1', 'B2', 'B3']);
$layouts = $this->loadLayouts($manager, $buildings);
$cases = $this->loadBuildingCases($manager, $buildings, $statuts);
$cases = $this->loadBuildingCases($manager, $buildings);
$this->loadCasePositions($manager, $layouts, $cases);
$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
*
@@ -126,34 +92,21 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
/**
* @param array<string, Building> $buildings
* @param array<string, Statut> $statuts
*
* @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);
$statusRanges = [
// B1
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'statut' => 'LB'],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'statut' => 'OC'],
['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'],
$caseRanges = [
['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
];
$result = [];
foreach ($statusRanges as $range) {
foreach ($caseRanges as $range) {
for ($caseNumber = $range['from']; $caseNumber <= $range['to']; ++$caseNumber) {
$code = sprintf('%s-C%d', $range['buildingCode'], $caseNumber);
@@ -169,7 +122,6 @@ class BuildingInfrastructureFixtures extends Fixture implements DependentFixture
->setCode($code)
->setCapacity(15)
->setIdBuilding($buildings[$range['buildingCode']])
->setStatut($statuts[$range['statut']])
;
$manager->persist($buildingCase);
}

View File

@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\State\Bovin\BovineProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context;
@@ -18,6 +22,12 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Entity]
#[ORM\Table(name: 'bovine')]
#[ORM\UniqueConstraint(name: 'uniq_bovine_national_number', columns: ['national_number'])]
#[ApiFilter(SearchFilter::class, properties: [
'nationalNumber' => 'ipartial',
'buildingCase' => 'exact',
'receivedWeight' => 'exact',
])]
#[ApiFilter(DateFilter::class, properties: ['arrivalDate'])]
#[ApiResource(
operations: [
new Get(
@@ -31,12 +41,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
),
new Patch(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
),
],
security: "is_granted('ROLE_USER')",
@@ -46,19 +58,19 @@ class Bovine
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['bovine:read'])]
#[Groups(['bovine:read', 'building_case:read'])]
private ?int $id = null;
#[ORM\Column(length: 50)]
#[Groups(['bovine:read', 'bovine:write'])]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private string $nationalNumber = '';
#[ORM\Column(nullable: true)]
#[Groups(['bovine:read', 'bovine:write'])]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?int $receivedWeight = null;
#[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'])]
private ?DateTimeImmutable $arrivalDate = null;
@@ -66,6 +78,23 @@ class Bovine
#[Groups(['bovine:read', 'bovine:write'])]
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;
public function getId(): ?int
{
return $this->id;
@@ -118,4 +147,52 @@ class Bovine
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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Entity;
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\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
@@ -15,8 +17,8 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading;
use App\State\ReceptionReceiptProvider;
use App\State\ReceptionWeighingProvider;
use App\State\Reception\ReceptionReceiptProvider;
use App\State\Reception\ReceptionWeighingProvider;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -30,6 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'reception')]
#[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(
order: ['id' => 'DESC'],
operations: [

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Entity;
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\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
@@ -15,8 +17,8 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation;
use App\Dto\PontBasculeReading;
use App\State\ShipmentReceiptProvider;
use App\State\ShipmentWeighingProvider;
use App\State\Shipment\ShipmentReceiptProvider;
use App\State\Shipment\ShipmentWeighingProvider;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -30,6 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\HasLifecycleCallbacks]
#[ORM\Table(name: 'shipment')]
#[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(
order: ['id' => 'DESC'],
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;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
@@ -17,6 +19,12 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ORM\Entity]
#[ORM\Table(name: 'supplier')]
#[ApiFilter(SearchFilter::class, properties: [
'name' => 'ipartial',
'email' => 'ipartial',
'phone' => 'ipartial',
'createdBy.username' => 'ipartial',
])]
#[ApiResource(
operations: [
new Get(

View File

@@ -4,20 +4,27 @@ declare(strict_types=1);
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\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\State\MeProvider;
use App\State\UserPasswordProcessor;
use App\State\User\ActiveUsersProvider;
use App\State\User\MeProvider;
use App\State\User\UserPasswordProcessor;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
#[ORM\Table(name: 'user', schema: 'public')]
#[ApiFilter(SearchFilter::class, properties: ['username' => 'ipartial'])]
#[ApiFilter(BooleanFilter::class, properties: ['isLocked'])]
#[ApiResource(
operations: [
new Get(
@@ -45,12 +52,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
),
new GetCollection(
normalizationContext: ['groups' => ['user-login:read']],
security: "is_granted('PUBLIC_ACCESS')"
security: "is_granted('PUBLIC_ACCESS')",
provider: ActiveUsersProvider::class
),
new GetCollection(
uriTemplate: '/admin/users',
normalizationContext: ['groups' => ['user:read']],
security: "is_granted('ROLE_ADMIN')"
security: "is_granted('ROLE_ADMIN')",
paginationEnabled: true
),
],
normalizationContext: ['groups' => ['user:read']],
@@ -76,6 +85,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['user:write'])]
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
{
return $this->id;
@@ -125,6 +139,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getIsLocked(): bool
{
return $this->isLocked;
}
public function setIsLocked(bool $isLocked): self
{
$this->isLocked = $isLocked;
return $this;
}
public function eraseCredentials(): void
{
// 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);
namespace App\State;
namespace App\State\Bovin;
use ApiPlatform\Metadata\Operation;
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

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\State;
namespace App\State\Building;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
@@ -11,10 +11,8 @@ use App\Entity\BuildingCase;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Malio\EdnotifBundle\Bovin\Api\BovinApiInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Throwable;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
@@ -40,7 +38,6 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
public function __construct(
private Environment $twig,
private EntityManagerInterface $entityManager,
private BovinApiInterface $bovinApi,
) {}
/**
@@ -68,24 +65,9 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
continue;
}
$workNumber = null;
$birthDate = null;
$breedCode = null;
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.
$breedCode = $bovine->getBreedCode();
if (null === $headerBreedCode && null !== $breedCode) {
$headerBreedCode = $breedCode;
}
$arrivalDate = $bovine->getArrivalDate();
@@ -101,8 +83,8 @@ final readonly class BuildingCaseWeightsReportProvider implements ProviderInterf
$rows[] = [
'nationalNumber' => $bovine->getNationalNumber(),
'workNumber' => $workNumber,
'birthDate' => $birthDate,
'workNumber' => $bovine->getWorkNumber(),
'birthDate' => $bovine->getBirthDate()?->format('d/m/y'),
'receivedWeight' => $bovine->getReceivedWeight(),
'arrivalDate' => $bovine->getArrivalDate()?->format('d/m/Y'),
'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
{
return 1.3;

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
declare(strict_types=1);
namespace App\State;
namespace App\State\System;
use ApiPlatform\Metadata\Operation;
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);
namespace App\State;
namespace App\State\User;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;

View File

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

View File

@@ -265,23 +265,15 @@
TABLEAU PRINCIPAL
========================= -->
<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>
<tr>
<th rowspan="4" class="head-big">N° de<br>travail</th>
<th rowspan="4" class="head-big head-big-weight">Poids<br>(kg)</th>
<th rowspan="4" class="head-big">Date de<br>naissance</th>
<th rowspan="4" class="head-big" style="width:5%">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" 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([]) %}
<th class="month">{{ month.name }}</th>
<th class="month" style="width:6.58%">{{ month.name }}</th>
{% endfor %}
</tr>
@@ -292,7 +284,9 @@
</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>
@@ -315,6 +309,7 @@
{% set baseWeight = row ? (row.receivedWeight ?? null) : null %}
<tr class="data-row">
<td class="row-work"></td>
<td class="row-work">{{ row ? (row.workNumber ?? '') : '' }}</td>
<td class="row-weight">{{ baseWeight ?? '' }}</td>
<td class="row-birth">

View File

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