Compare commits

..

16 Commits

Author SHA1 Message Date
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
gitea-actions
d0beb80199 chore: bump version to v0.0.76
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m40s
2026-03-23 17:04:21 +00:00
c378b402c4 fix : style bon de récéption
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-23 18:04:09 +01:00
gitea-actions
6e707484a0 chore: bump version to v0.0.75
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m22s
2026-03-23 16:26:46 +00:00
0067e51e6e fix : order récéption/expédition + correction style bouton récéption
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-23 17:26:29 +01:00
gitea-actions
1c0cdeb085 chore: bump version to v0.0.74
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-03-18 16:53:27 +00:00
465339cdd6 fix : order navbar + modification création fournisseur et client
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
2026-03-18 17:53:17 +01:00
gitea-actions
2bc484574f chore: bump version to v0.0.73
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m13s
2026-03-18 14:25:22 +00:00
ea1e3b074c fix : script de déploiement + CI/CD build de l'app
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-18 15:25:13 +01:00
39 changed files with 931 additions and 190 deletions

View File

@@ -2,7 +2,9 @@
"permissions": {
"allow": [
"Bash(npm run:*)",
"WebFetch(domain:geo.api.gouv.fr)"
"WebFetch(domain:geo.api.gouv.fr)",
"Bash(pip3 install:*)",
"Bash(python3 -c \":*)"
]
}
}

View File

@@ -36,7 +36,7 @@ jobs:
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ NUXT_PUBLIC_GEO_API_BASE=https://geo.api.gouv.fr npm run generate
test -f .output/public/index.html
- name: Build artefact

269
.idea/workspace.xml generated
View File

@@ -4,12 +4,7 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : correction des retours de la V0">
<change beforePath="$PROJECT_DIR$/.claude/settings.local.json" beforeDir="false" afterPath="$PROJECT_DIR$/.claude/settings.local.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/components/address.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/components/address.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/nuxt.config.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/nuxt.config.ts" afterDir="false" />
</list>
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : update CHANGELOG.md" />
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
@@ -40,7 +35,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-12-ajouter-un-blocage-des-utilisateurs" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -224,36 +219,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": "feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes",
"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" />
@@ -263,8 +258,8 @@
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="C:\Users\m-tristan\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="C:\Users\m-tristan\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
@@ -273,7 +268,7 @@
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.31033.138" />
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
</set>
</attachedChunks>
</component>
@@ -323,95 +318,9 @@
<workItem from="1773215356754" duration="5754000" />
<workItem from="1773756072697" duration="5450000" />
<workItem from="1773766075191" duration="6202000" />
<workItem from="1773824491213" duration="15167000" />
</task>
<task id="LOCAL-00025" summary="fix : modification du script de déploiement pour corriger le problème d'écriture des logs de prod">
<option name="closed" value="true" />
<created>1769079030808</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1769079030808</updated>
</task>
<task id="LOCAL-00026" summary="fix : doc de déploiement">
<option name="closed" value="true" />
<created>1769094376813</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1769094376813</updated>
</task>
<task id="LOCAL-00027" summary="fix : doc et script de déploiement">
<option name="closed" value="true" />
<created>1769096187792</created>
<option name="number" value="00027" />
<option name="presentableId" value="LOCAL-00027" />
<option name="project" value="LOCAL" />
<updated>1769096187792</updated>
</task>
<task id="LOCAL-00028" summary="fix : doc et script de déploiement">
<option name="closed" value="true" />
<created>1769097091268</created>
<option name="number" value="00028" />
<option name="presentableId" value="LOCAL-00028" />
<option name="project" value="LOCAL" />
<updated>1769097091268</updated>
</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>
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="28381000" />
</task>
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
@@ -717,7 +626,95 @@
<option name="project" value="LOCAL" />
<updated>1773841634554</updated>
</task>
<option name="localTasksCounter" value="74" />
<task id="LOCAL-00074" summary="feat : ajout de l'api de l'état pour chercher les villes via le CP">
<option name="closed" value="true" />
<created>1773842791819</created>
<option name="number" value="00074" />
<option name="presentableId" value="LOCAL-00074" />
<option name="project" value="LOCAL" />
<updated>1773842791819</updated>
</task>
<task id="LOCAL-00075" summary="fix : script de déploiement + CI/CD build de l'app">
<option name="closed" value="true" />
<created>1773843922376</created>
<option name="number" value="00075" />
<option name="presentableId" value="LOCAL-00075" />
<option name="project" value="LOCAL" />
<updated>1773843922377</updated>
</task>
<task id="LOCAL-00076" summary="fix : order navbar + modification création fournisseur et client">
<option name="closed" value="true" />
<created>1773852806120</created>
<option name="number" value="00076" />
<option name="presentableId" value="LOCAL-00076" />
<option name="project" value="LOCAL" />
<updated>1773852806121</updated>
</task>
<task id="LOCAL-00077" summary="fix : order récéption/expédition + correction style bouton récéption">
<option name="closed" value="true" />
<created>1774283204849</created>
<option name="number" value="00077" />
<option name="presentableId" value="LOCAL-00077" />
<option name="project" value="LOCAL" />
<updated>1774283204849</updated>
</task>
<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>
<option name="localTasksCounter" value="85" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -767,17 +764,6 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address et modification du numéro de réception" />
<MESSAGE value="feat : ajout de colonne pour les Supplier, Address. Modification du numéro de réception et ajout de fixtures" />
<MESSAGE value="feat : mise à jour du bon de réception" />
<MESSAGE value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
<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" />
@@ -792,7 +778,18 @@
<MESSAGE value="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu" />
<MESSAGE value="fix : on ne pèse plus automatiquement + fix message de création réception" />
<MESSAGE value="fix : correction des retours de la V0" />
<option name="LAST_COMMIT_MESSAGE" value="fix : correction des retours de la V0" />
<MESSAGE value="feat : ajout de l'api de l'état pour chercher les villes via le CP" />
<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" />
<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" />
<option name="LAST_COMMIT_MESSAGE" value="feat : update CHANGELOG.md" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>

View File

@@ -59,6 +59,10 @@ 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
### 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

@@ -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

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.72'
app.version: '0.0.80'

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
@@ -116,7 +116,7 @@
<div class="flex justify-center">
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
>Valider
</UiButton>
@@ -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

@@ -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,7 +29,8 @@ export const useWeighingStep = (options: UseWeighingStepOptions) => {
title,
showLoadingBox,
fetchWeight,
saveWeight
saveWeight,
saveWeightDraft
} = useWeighing({
mode: options.mode,
entity: options.entity,
@@ -71,6 +72,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

@@ -90,6 +90,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 +130,8 @@ export const useWeighing = ({
title,
showLoadingBox,
fetchWeight,
saveWeight
saveWeight,
saveWeightDraft
}
}

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

@@ -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

@@ -72,23 +72,6 @@
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/carrier/carrier-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/carrier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Transporteurs
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/customer/customer-list"
@@ -106,6 +89,23 @@
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/carrier/carrier-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/carrier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Transporteurs
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/bovin/bovin-list"
@@ -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

@@ -14,6 +14,19 @@
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</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"
@@ -26,6 +39,7 @@
</UiButton>
</div>
<template v-if="customerId">
<div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2>
</div>
@@ -77,6 +91,7 @@
Ajouter
</UiButton>
</div>
</template>
</form>
</template>
@@ -84,6 +99,8 @@
import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
import {createAddress, type AddressPayload} from "~/services/address"
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
import {useAuthStore} from "~/stores/auth"
const route = useRoute()
@@ -106,6 +123,30 @@ const form = reactive<CustomerFormData>({
addresses: [],
})
// Address form (creation mode only)
const addressForm = reactive<AddressPayload>({
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
})
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => addressForm.postalCode, (cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
else addressForm.city = ''
} finally { isLoadingCities.value = false }
}, 300)
}
})
const goToAddAddress = () => {
if (customerId.value === null || !auth.isAdmin) return
router.push({
@@ -187,8 +228,11 @@ async function validate() {
await updateCustomer(customerId.value, customerPayload)
targetId = customerId.value
} else {
const addressData = await createAddress({ ...addressForm })
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...customerPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createCustomer(creationPayload)

View File

@@ -15,6 +15,19 @@
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div>
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</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"
@@ -27,6 +40,7 @@
</UiButton>
</div>
<template v-if="supplierId">
<div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2>
</div>
@@ -78,6 +92,7 @@
Ajouter
</UiButton>
</div>
</template>
</form>
</template>
@@ -85,6 +100,8 @@
import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
import {createAddress, type AddressPayload} from "~/services/address"
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
import {useAuthStore} from "~/stores/auth"
const route = useRoute()
@@ -107,6 +124,30 @@ const form = reactive<SupplierFormData>({
addresses: [],
})
// Address form (creation mode only)
const addressForm = reactive<AddressPayload>({
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
})
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => addressForm.postalCode, (cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
else addressForm.city = ''
} finally { isLoadingCities.value = false }
}, 300)
}
})
const goToAddAddress = () => {
if (supplierId.value === null || !auth.isAdmin) return
router.push({
@@ -190,8 +231,11 @@ async function validate() {
await updateSupplier(supplierId.value, supplierPayload)
targetId = supplierId.value
} else {
const addressData = await createAddress({ ...addressForm })
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...supplierPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createSupplier(creationPayload)

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

@@ -4,9 +4,10 @@
</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 class="grid grid-cols-3 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>Statut</div>
</div>
<div v-if="userList.length === 0" class="px-4 py-6 text-slate-400">
Aucun utilisateur.
@@ -15,7 +16,7 @@
<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"
class="grid grid-cols-3 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)"
@@ -23,6 +24,16 @@
>
<div>{{ user.username }}</div>
<div>{{ getRoleLabels(user.roles) }}</div>
<div>
<span
v-if="user.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>
</div>
</div>
</template>
</div>

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()

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,6 +18,7 @@
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<WorkflowWeight
v-if="storeShipment?.currentStep === 1"
ref="grossWeightRef"
mode="gross"
entity-name="shipment"
api-resource="shipments"
@@ -33,6 +34,7 @@
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<WorkflowWeight
v-if="storeShipment?.currentStep === 3"
ref="tareWeightRef"
mode="tare"
entity-name="shipment"
api-resource="shipments"
@@ -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

@@ -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,29 @@
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 }
}

View File

@@ -0,0 +1,14 @@
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
buildingCase: string | null
}
export type BovinePayload = {
nationalNumber?: string
receivedWeight?: number | null
arrivalDate?: string | null
buildingCase?: 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,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

@@ -75,9 +75,5 @@ chmod o+rx "$(dirname "$DEPLOY_DIR")" "$DEPLOY_DIR" 2>/dev/null || true
echo "Release ${TAG} deployed to ${DEPLOY_DIR}"
if [ -f "${DEPLOY_DIR}/.env.local" ]; then
echo "Running migrations (if any)..."
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod
else
echo "Skip migrations: ${DEPLOY_DIR}/.env.local not found" >&2
fi
echo "Running migrations (if any)..."
php "${DEPLOY_DIR}/bin/console" doctrine:migrations:migrate --no-interaction --env=prod

View File

@@ -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

@@ -17,6 +17,10 @@ use Symfony\Component\Serializer\Attribute\SerializedName;
#[ORM\Entity]
#[ApiResource(
operations: [
new Get(
requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['building:read']],
),
new Get(
uriTemplate: '/building_cases/{id}/weights-report',
requirements: ['id' => '\d+'],

View File

@@ -31,6 +31,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiResource(
order: ['id' => 'DESC'],
operations: [
new Get(
requirements: ['id' => '\d+'],

View File

@@ -31,6 +31,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiResource(
order: ['id' => 'DESC'],
operations: [
new Get(
requirements: ['id' => '\d+'],

View File

@@ -9,12 +9,14 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\State\ActiveUsersProvider;
use App\State\MeProvider;
use App\State\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')]
@@ -45,7 +47,8 @@ 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',
@@ -76,6 +79,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 +133,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,20 @@
<?php
declare(strict_types=1);
namespace App\State;
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

@@ -140,7 +140,7 @@
</td>
<td style="width:30%; text-align:right; vertical-align:top; font-size: 14px;">
<div style="display:inline-block; width:75mm; line-height:1.3;">
<div style="display:inline-block; line-height:1.3; border: 1px solid black; padding: 8px; border-radius: 8px; width: 60mm">
<strong>{{ reception.supplier ? reception.supplier.name : '-' }}</strong><br>
<span>{{ reception.address ? reception.address.street : '' }}</span><br>
{% if reception.address and reception.address.street2 %}