Compare commits

..

36 Commits

Author SHA1 Message Date
6845a6a332 fix : README.md 2026-04-10 11:55:44 +02:00
40f8bb40c9 fix : README.md 2026-04-10 11:41:23 +02:00
c84aa27d2c fix : README.md 2026-04-10 11:21:04 +02:00
77b9323615 feat : update CHANGELOG.md 2026-04-10 11:18:06 +02:00
6bf194b280 feat : update CHANGELOG.md 2026-04-10 11:00:36 +02:00
cdc9c33f4e feat : update CHANGELOG.md 2026-04-10 10:53:29 +02:00
b45e2d3a95 feat : écran d'ajout bovin + feed bovin + fix pesées expéditions 2026-04-10 10:29:16 +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
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
gitea-actions
4944611088 chore: bump version to v0.0.72
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m17s
2026-03-18 14:06:35 +00:00
fbfc7acfe4 feat : ajout de l'api de l'état pour chercher les villes via le CP
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-18 15:06:21 +01:00
gitea-actions
92f54f600f chore: bump version to v0.0.71
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m10s
2026-03-18 13:47:14 +00:00
a905c6a1de fix : correction des retours de la V0
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-18 14:47:03 +01:00
gitea-actions
995e7de2cc chore: bump version to v0.0.70
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m23s
2026-03-18 09:38:23 +00:00
2408ccab67 fix : on ne pèse plus automatiquement + fix message de création réception
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-18 10:38:11 +01:00
82af4d4c1e feat : ajout de bâtiment dans les fixtures et seed + organisation du menu 2026-03-17 17:49:58 +01:00
gitea-actions
11491b02c5 chore: bump version to v0.0.69
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m19s
2026-03-17 15:36:28 +00:00
024af5887e feat : ajout de supplier dans la feed et fixtures
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-17 16:36:19 +01:00
gitea-actions
91c0125876 chore: bump version to v0.0.68
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m12s
2026-03-02 10:32:55 +00:00
b510cdcc42 fix : on ne bloque plus le poids max d'une pesée
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
2026-03-02 11:32:44 +01:00
110 changed files with 3936 additions and 2342 deletions

View File

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

View File

@@ -36,7 +36,7 @@ jobs:
run: | run: |
cd frontend cd frontend
npm ci 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 test -f .output/public/index.html
- name: Build artefact - name: Build artefact

19
.idea/dataSources.xml generated
View File

@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="ferme" uuid="f407a514-c6b4-4b26-9555-445a85892502">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/ferme</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="Ferme recette" uuid="ae622167-c834-4e7b-87a5-c1721036f5dc">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="db-tree-configuration"> <component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:f407a514-c6b4-4b26-9555-445a85892502&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;" /> <option name="data" value="----------------------------------------&#10;1:0:f407a514-c6b4-4b26-9555-445a85892502&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:9cad43df-2147-4989-b7a4-443067034884&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component> </component>
</project> </project>

352
.idea/workspace.xml generated
View File

@@ -4,10 +4,12 @@
<option name="autoReloadType" value="SELECTIVE" /> <option name="autoReloadType" value="SELECTIVE" />
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : front page admin bovin et changelog"> <list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" /> <change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/admin/supplier/supplier-list.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/admin/supplier/supplier-list.vue" afterDir="false" /> <change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -39,7 +41,7 @@
<component name="Git.Settings"> <component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY"> <option name="RECENT_BRANCH_BY_REPOSITORY">
<map> <map>
<entry key="$PROJECT_DIR$" value="feat/356-front-page-admin-bovin" /> <entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -230,14 +232,14 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true", "RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true", "RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "feat/352-modification-front-admin-fournisseur", "git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"last_opened_file_path": "/home/sroy/Documents/test/Ferme/frontend/components/commun", "last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"node.js.detected.package.eslint": "true", "node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true", "node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)", "node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)", "node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm", "nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "preferences.pluginManager", "settings.editor.selected.configurable": "advanced.settings",
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external", "ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
"vue.rearranger.settings.migration": "true" "vue.rearranger.settings.migration": "true"
}, },
@@ -255,27 +257,28 @@
}]]></component> }]]></component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS"> <key name="CopyFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/frontend/components/commun" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="$PROJECT_DIR$/frontend/components/commun" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\pages\shipment" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\pages\shipment" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\composables" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\composables" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" /> <recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
</key> </key>
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" /> <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="\\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" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" /> <recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages\reception" />
</key> </key>
</component> </component>
<component name="SharedIndexes"> <component name="SharedIndexes">
<attachedChunks> <attachedChunks>
<set> <set>
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.30387.85" /> <option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
</set> </set>
</attachedChunks> </attachedChunks>
</component> </component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager"> <component name="TaskManager">
<task active="true" id="Default" summary="Default task"> <task active="true" id="Default" summary="Default task">
<changelist id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="" /> <changelist id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="" />
@@ -310,142 +313,20 @@
<workItem from="1770879701502" duration="25805000" /> <workItem from="1770879701502" duration="25805000" />
<workItem from="1770966186589" duration="914000" /> <workItem from="1770966186589" duration="914000" />
<workItem from="1770967274060" duration="2388000" /> <workItem from="1770967274060" duration="2388000" />
</task> <workItem from="1772466451823" duration="598000" />
<task id="LOCAL-00020" summary="ci : fix release artefact"> <workItem from="1772626984813" duration="969000" />
<option name="closed" value="true" /> <workItem from="1772786360430" duration="21000" />
<created>1769024603812</created> <workItem from="1772786475316" duration="3016000" />
<option name="number" value="00020" /> <workItem from="1773049125640" duration="406000" />
<option name="presentableId" value="LOCAL-00020" /> <workItem from="1773049540928" duration="539000" />
<option name="project" value="LOCAL" /> <workItem from="1773050154207" duration="1879000" />
<updated>1769024603812</updated> <workItem from="1773212999001" duration="652000" />
</task> <workItem from="1773215356754" duration="5754000" />
<task id="LOCAL-00021" summary="ci : ajout du script et de la doc déploiement"> <workItem from="1773756072697" duration="5450000" />
<option name="closed" value="true" /> <workItem from="1773766075191" duration="6202000" />
<created>1769026716634</created> <workItem from="1773824491213" duration="24805000" />
<option name="number" value="00021" /> <workItem from="1774275549972" duration="51000" />
<option name="presentableId" value="LOCAL-00021" /> <workItem from="1774276665015" duration="33750000" />
<option name="project" value="LOCAL" />
<updated>1769026716634</updated>
</task>
<task id="LOCAL-00022" summary="fix : correction du path URI pour la création d'un poids dans une réception">
<option name="closed" value="true" />
<created>1769073690382</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1769073690382</updated>
</task>
<task id="LOCAL-00023" summary="feat : Ajout du bundle Monolog pour la gestion des logs">
<option name="closed" value="true" />
<created>1769075990984</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1769075990984</updated>
</task>
<task id="LOCAL-00024" summary="fix : affiche plus détail dans les logs en recette/prod">
<option name="closed" value="true" />
<created>1769077633390</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1769077633390</updated>
</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>
</task>
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1769435038236</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1769435038236</updated>
</task> </task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)"> <task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -703,7 +584,143 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1772182707441</updated> <updated>1772182707441</updated>
</task> </task>
<option name="localTasksCounter" value="69" /> <task id="LOCAL-00069" summary="fix : on ne bloque plus le poids max d'une pesée">
<option name="closed" value="true" />
<created>1772447581744</created>
<option name="number" value="00069" />
<option name="presentableId" value="LOCAL-00069" />
<option name="project" value="LOCAL" />
<updated>1772447581744</updated>
</task>
<task id="LOCAL-00070" summary="feat : ajout de supplier dans la feed et fixtures">
<option name="closed" value="true" />
<created>1773761787472</created>
<option name="number" value="00070" />
<option name="presentableId" value="LOCAL-00070" />
<option name="project" value="LOCAL" />
<updated>1773761787472</updated>
</task>
<task id="LOCAL-00071" summary="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu">
<option name="closed" value="true" />
<created>1773766207721</created>
<option name="number" value="00071" />
<option name="presentableId" value="LOCAL-00071" />
<option name="project" value="LOCAL" />
<updated>1773766207721</updated>
</task>
<task id="LOCAL-00072" summary="fix : on ne pèse plus automatiquement + fix message de création réception">
<option name="closed" value="true" />
<created>1773826699115</created>
<option name="number" value="00072" />
<option name="presentableId" value="LOCAL-00072" />
<option name="project" value="LOCAL" />
<updated>1773826699115</updated>
</task>
<task id="LOCAL-00073" summary="fix : correction des retours de la V0">
<option name="closed" value="true" />
<created>1773841634554</created>
<option name="number" value="00073" />
<option name="presentableId" value="LOCAL-00073" />
<option name="project" value="LOCAL" />
<updated>1773841634554</updated>
</task>
<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>
<task id="LOCAL-00085" summary="feat : la page de scanner est accessible que pour les admins">
<option name="closed" value="true" />
<created>1774543840891</created>
<option name="number" value="00085" />
<option name="presentableId" value="LOCAL-00085" />
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<option name="localTasksCounter" value="86" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -753,24 +770,6 @@
</option> </option>
</component> </component>
<component name="VcsManagerConfiguration"> <component name="VcsManagerConfiguration">
<MESSAGE value="feat : ajout de la partie reception des marchandises (étape 3) et modification du bon de réception" />
<MESSAGE value="feat : mise en place de composant UI pour les select, checkbox, date, text" />
<MESSAGE value="feat : update CHANGELOG.md" />
<MESSAGE value="feat : ajout de commentaire" />
<MESSAGE value="fix : correction de l'affichage de l'immatriculation sur une réception en cours + correction css étape 3 d'une réception" />
<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" /> <MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" /> <MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" /> <MESSAGE value="feat : modification front de la page admin transporteur" />
@@ -778,7 +777,25 @@
<MESSAGE value="fix : espacement" /> <MESSAGE value="fix : espacement" />
<MESSAGE value="fix : text" /> <MESSAGE value="fix : text" />
<MESSAGE value="feat : front page admin bovin et changelog" /> <MESSAGE value="feat : front page admin bovin et changelog" />
<option name="LAST_COMMIT_MESSAGE" value="feat : front page admin bovin et changelog" /> <MESSAGE value="fix : on ne bloque plus le poids max d'une pesée" />
<MESSAGE value="feat : ajout de supplier dans la feed et fixtures" />
<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" />
<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" />
<MESSAGE value="feat : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
</component> </component>
<component name="XDebuggerManager"> <component name="XDebuggerManager">
<breakpoint-manager> <breakpoint-manager>
@@ -794,9 +811,8 @@
<option name="timeStamp" value="45" /> <option name="timeStamp" value="45" />
</line-breakpoint> </line-breakpoint>
<line-breakpoint enabled="true" type="javascript"> <line-breakpoint enabled="true" type="javascript">
<url>file://$PROJECT_DIR$/frontend/layouts/default.vue</url> <url>file://$PROJECT_DIR$/frontend/services/dto/shipment-data.ts</url>
<line>72</line> <option name="timeStamp" value="43" />
<option name="timeStamp" value="48" />
</line-breakpoint> </line-breakpoint>
</breakpoints> </breakpoints>
</breakpoint-manager> </breakpoint-manager>

View File

@@ -1,73 +0,0 @@
# AGENTS.md
Project overview
- Symfony 8 + API Platform 4 backend, Nuxt 3 frontend in `frontend/`.
- Apache vhost serves API under `/api` and frontend from `frontend/dist`.
- API base URL on frontend uses `NUXT_PUBLIC_API_BASE` (see `frontend/.env`).
Backend conventions
- Use English for code identifiers/messages; keep “pont-bascule” as domain term.
- API Platform operations are defined on Doctrine entities.
- No custom repository classes are used (`src/Repository` removed); use default Doctrine repositories via `EntityManagerInterface`.
- Reception entity is in `src/Entity/Reception.php`, with custom weigh endpoint `/receptions/weigh`.
- Reception fields: `date_reception`, `license_plate`, `current_step` (default 0), `is_valid` (default false).
- Reception also has `identification_number` (auto `N-BR-####`), `merchandise_type`, `merchandise_detail`, `buildings` (M2M), and `pellet_buildings` (via `reception_pellet_building`).
- `date_reception` is set by the UI, stored as `DateTimeImmutable`, serialized as `Y-m-d`.
- Weight entity (`src/Entity/Weight.php`) is 1N with Reception, each row stores `type` (`gross` or `tare`), `dsd`, `weight`, `weighed_at` (all nullable except `type`).
- Weigh endpoint `/receptions/weigh` returns `PontBasculeReading` with `dsd`, `weight`, `weighedAt` (formatted `Y-m-d`).
- Custom exception: `App\Exception\PontBasculeException` with French messages, mapped to 500 in provider.
- Parsing of pont-bascule payload is in `src/Service/PontBasculePayloadDecoder.php`.
- `config/reference.php` is auto-generated; keep it.
- Bovine storage:
- `src/Entity/Bovine.php` with fields `nationalNumber` (unique), `receivedWeight`, `arrivalDate`, and `buildingCase` (ManyToOne).
- `src/Entity/BuildingCase.php` has `bovines` (OneToMany).
- Case PDF report:
- Endpoint: `GET /building_cases/{id}/weights-report` (provider: `App\State\BuildingCaseWeightsReportProvider`).
- Template: `templates/case_weights_report.html.twig`.
- Projection logic is done in backend from `arrivalDate`; daily gain is currently fixed at `1.3 kg/day` for all races.
Frontend conventions
- Nuxt SSR disabled; Tailwind used.
- Layout in `frontend/layouts/default.vue`: max width `1050px`, header full width.
- Tailwind custom color palette is `primary` (e.g. `bg-primary-500`).
- Global font stack uses Helvetica via Tailwind (`font-sans`) and `frontend/assets/css/main.css`.
- API composable in `frontend/composables/useApi.ts` with `get/post/put/patch/delete` and default JSON/PATCH content types.
- API errors/success toasts can be customized via `toastErrorMessage`/`toastSuccessMessage` or i18n keys `toastErrorKey`/`toastSuccessKey`. Global method fallbacks use `errors.http.*` keys.
- `useApi` uses `useNuxtApp().$i18n` (not `useI18n`) to avoid setup-only constraint in service calls.
- Pinia store: `frontend/stores/reception.ts` is the source of truth for the current reception.
- Zod is used for form validation (e.g. `frontend/components/reception/reception-form.vue`); shared helpers live in `frontend/utils/zod-errors.ts`.
- Weighing logic is shared via `frontend/composables/useWeighing.ts`.
- Reception step UI uses store state (`currentStep`) in `frontend/pages/reception/[[id]].vue`.
- Step 2 uses `frontend/components/reception/reception-product-received.vue` for merchandise selection; type codes in `frontend/utils/constants.ts`.
- Active nav styles in header use `NuxtLink` with `custom` slot.
- Reusable UI components live under `frontend/components/ui/` and are auto-imported with `Ui` prefix (e.g. `UiLoadingDots`).
- Service layer lives in `frontend/services/` with typed DTOs in `frontend/services/dto/`.
- Reception service uses `receptions`, `receptions/{id}`, `receptions/weigh` and supports success/error toast keys.
- Reception receipt endpoint is `receptions/{id}/receipt` (PDF) via `frontend/composables/usePdfPrinter.ts`.
- Infrastructure case page prints the case weight report PDF from `frontend/pages/infrastructure/case.vue` using `usePdfPrinter('/building_cases/{id}/weights-report')`.
Environment & routing
- Frontend dev server: `npm run dev` in `frontend/`.
- API base for local dev: `http://localhost:8080/api` (set in `frontend/.env` via `NUXT_PUBLIC_API_BASE`).
- CORS handled by Nelmio; `.env` includes `CORS_ALLOW_ORIGIN` regex for localhost.
- Nuxt i18n locales live in `frontend/i18n/locales` (configured via `langDir: 'locales'`).
- Default locale is `fr`; translations in `frontend/i18n/locales/fr.json`.
Notes
- Do not add a GET that creates resources; use POST + PATCH.
- Keep endpoints in plural (API Platform convention).
- Seed and fixtures conventions:
- `app:seed` now seeds infrastructure (`statut`, `building_layout`, `building_case`, `building_case_position`) and bovines.
- `app:seed` uses intermediate flushes (after buildings and after infrastructure) so find queries can resolve just-created records.
- Bovine seed rows use a legacy case token mapping to building-case code (`B{building}-C{case}`) before fallback to direct id lookup.
- Fixtures include `BuildingInfrastructureFixtures` + `BovineFixtures` (via `AppFixtures` dependencies).
- New reference data added:
- Reception types (`reception_type`, fields: `label`, `code`), selectable on reception form.
- Merchandise types (`merchandise_type`, fields: `label`, `code`) and pellet types (`pellet_type`, fields: `label`, `code`).
- Buildings (`building`, fields: `label`, `code`) and reception allocations (`reception_building` M2M, `reception_pellet_building` unique on reception/pellet/building).
- Suppliers (`supplier`) with addresses (`address`, fields: `label`, `street`, `postal_code`, `city`, `country_code` ISO2), via `supplier_address` join table.
- Trucks (`truck`, field: `name`), linked to receptions.
- Carriers (`carrier`, fields: `name`, nullable `code`), Drivers (`driver`, fields: `name`, `carrier_id`), Vehicles (`vehicle`, fields: `plate`, `carrier_id`, `truck_id`) used for LIOT logic.
- Reception links: `reception_type_id`, `supplier_id`, `address_id`, `truck_id`, `carrier_id`, `driver_id`, `user_id`.
- Address exposes `fullAddress` via getter for display.
- LIOT behavior in reception form: if carrier code = `LIOT`, show driver + vehicle selects and hide manual license plate input; vehicle list filters by truck type and carrier; selected vehicle sets `license_plate`.

View File

@@ -59,6 +59,12 @@ Ajouter dans le fichier .env du frontend
* [#356] front page admin bovin * [#356] front page admin bovin
* [#353] modification front admin client * [#353] modification front admin client
* [#353] modification front admin utilisateur * [#353] modification front admin utilisateur
* [#FER-11] Corriger le problème de bearer token
* [#FER-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
### Changed ### Changed
### Fixed ### Fixed

169
CLAUDE.md Normal file
View File

@@ -0,0 +1,169 @@
# CLAUDE.md
## Stack
- **Backend:** Symfony 8 + API Platform 4 (PHP 8.4)
- **Frontend:** Nuxt 4 (Vue 3, Pinia, Tailwind, Zod) in `frontend/`
- **Infra:** Docker (PHP-FPM + Nginx), Apache vhost serves API sous `/api` et frontend depuis `frontend/dist`
## Commands
```bash
# Docker
make start # Démarrer les containers
make stop # Arrêter les containers
make restart # Redémarrer les containers
make shell # Shell dans le container PHP
# Install complet
make install # composer install + migrations + build frontend
# Backend
make composer-install # Installer dépendances PHP
make migration-migrate # Lancer les migrations
make fixtures # Charger les fixtures
make cache-clear # Vider le cache Symfony
make test # Lancer les tests PHPUnit
make test FILES=tests/path/to/TestFile.php # Test spécifique
make php-cs-fixer-allow-risky FILES=src/... # Fixer le style
# Frontend
make build-nuxtJS # npm install + build:dist (dans le container)
make dev-nuxt # Serveur dev Nuxt (dans le container)
# Ou directement dans frontend/ :
cd frontend && npm run dev # Dev server (port 3000)
cd frontend && npm run build:dist # Build production
# Base de données
make db-reset # ⚠️ Supprime et recrée la BDD + migrations + fixtures
```
## Architecture backend
```
src/
├── ApiResource/ # Ressources API Platform custom
├── Command/ # Commandes Symfony (dont app:seed)
├── DataFixtures/ # Fixtures Doctrine
├── Dto/ # DTOs (ex: PontBasculeReading)
├── Entity/ # Entités Doctrine (= ressources API Platform)
├── Exception/ # Exceptions custom (PontBasculeException)
├── Kernel.php
├── Service/ # Services métier (PontBasculePayloadDecoder…)
└── State/ # State providers/processors API Platform
```
## Architecture frontend
```
frontend/
├── components/
│ ├── ui/ # Composants réutilisables, auto-importés avec préfixe Ui (ex: UiLoadingDots)
│ ├── reception/ # Composants métier réception
│ ├── shipment/ # Composants métier expédition
│ ├── workflow/ # Composants partagés réception/expédition (workflow-weight, workflow-waiting-list, workflow-liot-fields)
│ └── commun/ # Composants communs (update-weight)
├── composables/ # useApi, useWeighing, usePdfPrinter, useAppVersion, useLiotHandling, useFormDataLoading, useAddressSync, useWorkflowSteps
│ └── steps/ # useWeighingStep (logique étape pesée)
├── config/ # reception.config.ts, shipment.config.ts (WorkflowConfig)
├── types/ # workflow.ts (interfaces partagées WorkflowEntity, WorkflowConfig, StepDefinition)
├── services/ # Couche service avec DTOs typés dans services/dto/
│ └── workflow-service.ts # Factory service API (createWorkflowService)
├── stores/ # Pinia stores (reception, shipment, auth)
│ └── workflow-store.ts # Factory store (useWorkflowStoreLogic)
├── pages/ # Pages Nuxt (file-based routing)
├── layouts/ # Layout default : max-width 1050px
├── i18n/locales/ # Traductions (défaut: fr)
├── utils/ # Constants, zod-errors helpers
└── assets/css/ # Tailwind config, main.css (font Helvetica)
```
## Conventions backend
- Code en anglais ; "pont-bascule" est un terme métier conservé tel quel.
- Les opérations API Platform sont définies directement sur les entités Doctrine.
- Pas de classes Repository custom : utiliser `EntityManagerInterface` avec les repos par défaut.
- `config/reference.php` est auto-généré — ne pas modifier à la main.
- Endpoints toujours au pluriel (convention API Platform).
- Ne jamais créer de GET qui crée des ressources : utiliser POST + PATCH.
- Les noms de `Supplier`, `Customer` et `Carrier` sont automatiquement mis en majuscule via `mb_strtoupper` dans `setName()`.
## Conventions frontend
- SSR désactivé. Tailwind avec palette custom `primary` (ex: `bg-primary-500`).
- `useApi` (`composables/useApi.ts`) : méthodes `get/post/put/patch/delete` avec content-types par défaut.
- Toasts personnalisables via `toastErrorMessage`/`toastSuccessMessage` ou clés i18n `toastErrorKey`/`toastSuccessKey`.
- Utilise `useNuxtApp().$i18n` (pas `useI18n`) pour fonctionner hors setup.
- Validation formulaires avec Zod ; helpers dans `utils/zod-errors.ts`.
- Nav active : `NuxtLink` avec slot `custom`.
- PDFs : `usePdfPrinter` (receipt réception, rapport poids cases).
### Validation required & erreurs visuelles
- Les champs `required` utilisent l'attribut HTML natif forwardé via `v-bind="attrs"` dans les composants UI.
- La bordure rouge n'apparaît qu'après soumission grâce à la classe CSS `submitted` ajoutée sur le `<form>` au clic sur le bouton Valider (`@click="submitted = true"`).
- Règles CSS globales dans `main.css` : `.submitted :invalid` (bordure + texte rouge), `.submitted :has(:invalid) > label` et `.submitted label:has(:invalid)` (labels rouges).
- Pour les validations manuelles (checkboxes, radio groups), les messages d'erreur utilisent `invisible` (pas `hidden`) pour garder l'espace réservé et éviter les décalages de layout.
- Les dates de l'API sont renvoyées au format `Y-m-d H:i` ; les formulaires utilisent `.slice(0, 10)` pour extraire la date seule (compatible `<input type="date">`).
### Workflow réception/expédition (mutualisé)
- Factory service `createWorkflowService` et factory store `useWorkflowStoreLogic` pour éviter la duplication.
- Composables partagés : `useLiotHandling` (logique LIOT), `useFormDataLoading` (users, trucks, carriers), `useAddressSync` (sync adresse fournisseur/client).
- `useWeighing` : une seule fonction paramétrée pour réception et expédition (remplace `useWeighing` + `useWeighingShipment`).
- Configs workflow dans `config/reception.config.ts` et `config/shipment.config.ts` (étapes, labels pesée, filename PDF).
- `WorkflowWeight` composant partagé pour les étapes de pesée (remplace `reception-weight.vue` et `shipment-weight.vue`).
- `WorkflowWaitingList` composant partagé pour les listes en attente, avec support colonnes dynamiques, slot `actions`, et prop `showActions`.
## Domaine métier clé
### Réception (pesée pont-bascule)
- Entité principale `Reception` : `date_reception` (DateTimeImmutable, format lecture `Y-m-d H:i`, écriture `Y-m-d`), `identification_number` (auto `N-BR-####`), `current_step` (défaut 0), `is_valid` (défaut false).
- `Weight` (1-N avec Reception, cascade remove + orphanRemoval) : `type` (`gross`/`tare`), `dsd`, `weight`, `weighed_at`.
- Endpoint pesée : `/receptions/weigh``PontBasculeReading` (dsd, weight, weighedAt).
- Endpoint suppression : `DELETE /receptions/{id}` — supprime en cascade weights, pelletBuildings, bovines.
- Parsing payload pont-bascule : `Service/PontBasculePayloadDecoder.php`.
- Exception : `PontBasculeException` (messages en français, mappée 500).
- Store Pinia `reception.ts` = source de vérité pour la réception en cours.
- UI multi-étapes dans `pages/reception/[[id]].vue` basée sur `currentStep`.
- `PrePersist` : injecte l'heure courante sur `receptionDate` à la création ; `setReceptionDate` préserve l'heure existante au PATCH.
### Expédition
- Entité `Shipment` : même pattern que Reception, `shipment_date` (format lecture `Y-m-d H:i`, écriture `Y-m-d`).
- Endpoint suppression : `DELETE /shipments/{id}`.
- `PrePersist` : injecte l'heure courante sur `shipmentDate` ; `setShipmentDate` préserve l'heure au PATCH.
### LIOT (transport)
- Si carrier code = `LIOT` : afficher sélecteurs driver + vehicle, masquer saisie plaque manuelle.
- Liste véhicules filtrée par type de camion et transporteur.
- Le véhicule sélectionné alimente `license_plate`.
- Logique mutualisée dans `composables/useLiotHandling.ts`.
### Bovins & infrastructure
- `Bovine` : `nationalNumber` (unique), `receivedWeight`, `arrivalDate`, `buildingCase` (ManyToOne).
- `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.
### Seed & fixtures
- Commande `app:seed` : seed infrastructure (statut, building_layout, building_case, building_case_position) puis bovins.
- Utilise des flush intermédiaires pour que les queries find fonctionnent sur les records fraîchement créés.
- `upsertAddress` cherche par `street|postalCode` (plus par `label`).
- Fixtures : `BuildingInfrastructureFixtures` + `BovineFixtures` (via dépendances `AppFixtures`).
## Environnement
- API base dev : `http://localhost:8080/api` (via `NUXT_PUBLIC_API_BASE` dans `frontend/.env`)
- CORS : Nelmio, configurable via `CORS_ALLOW_ORIGIN` dans `.env`
- Locale par défaut : `fr` — traductions dans `frontend/i18n/locales/fr.json`
- Docker env : `docker/.env.docker` (défaut) avec override possible via `docker/.env.docker.local`

View File

@@ -51,6 +51,7 @@ Vous pouvez modifier le port si nécessaire.
La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter. La bdd est déja pré-configuré dans PhpStorm, il suffit de rentrer les infos du .env.docker.local pour se connecter.
C'est un bdd local dans le docker. C'est un bdd local dans le docker.
### Frontend ### Frontend
Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev Pour le frontend, il suffit de taper la commande suivante qui va lancer le serveur de dev
```bash ```bash
@@ -92,11 +93,13 @@ Le .env se trouve /var/www/ferme/.env
Le script de livraison est version dans le repo dans script/deploy-release.sh <br> Le script de livraison est version dans le repo dans script/deploy-release.sh <br>
Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br> Sur la machine, il est disponible dans /usr/local/bin/deploy-ferme <br>
Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme Pour le modifier, il faut copier le contenu du deploy-release.sh dans le deploy-ferme
### Livraison ### Livraison
Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer Sur le serveur de recette, il suffit d'utiliser cette commande pour livrer
```bash ```bash
/usr/local/bin/deploy-ferme vX.Y.Z /usr/local/bin/deploy-ferme vX.Y.Z
``` ```
## Commandes utiles ## Commandes utiles
Pour restart le container Pour restart le container
```bash ```bash

View File

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

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.67' app.version: '0.0.81'

View File

@@ -7,3 +7,17 @@
@apply font-sans; @apply font-sans;
} }
} }
@layer utilities {
.submitted :invalid {
@apply border-red-500 text-red-500;
}
.submitted :has(:invalid) > label {
@apply text-red-500;
}
.submitted label:has(:invalid) {
@apply text-red-500;
}
}

View File

@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validateForm"> <form :class="{ submitted }" @submit.prevent="validateForm">
<div class="flex items-center mb-11 justify-between relative"> <div class="flex items-center mb-11 justify-between relative">
<div class="flex flex-row absolute -left-[60px] "> <div class="flex flex-row absolute -left-[60px] ">
<Icon @click="goBack" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/> <Icon @click="goBack" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
@@ -10,11 +10,18 @@
</div> </div>
<div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16"> <div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16">
<UiTextInput id="address-label" v-model="form.label" label="Libellé" /> <UiTextInput id="address-street" v-model="form.street" label="Rue" required />
<UiTextInput id="address-street" v-model="form.street" label="Rue" />
<UiTextInput id="address-street2" v-model="form.street2" label="Complément" /> <UiTextInput id="address-street2" v-model="form.street2" label="Complément" />
<UiTextInput id="address-postalCode" v-model="form.postalCode" label="Code postal" /> <UiTextInput id="address-postalCode" v-model="form.postalCode" label="Code postal" required />
<UiTextInput id="address-city" v-model="form.city" label="Ville" /> <UiSelect
id="address-city"
v-model="form.city"
label="Ville"
:options="communeOptions"
:loading="isLoadingCities"
:disabled="communes.length === 0"
required
/>
<UiTextInput id="address-country" v-model="form.countryCode" label="Pays (code)" /> <UiTextInput id="address-country" v-model="form.countryCode" label="Pays (code)" />
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
@@ -22,6 +29,7 @@
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading" :disabled="isLoading"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -31,7 +39,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AddressPayload } from "~/services/address" import type { AddressPayload } from "~/services/address"
import { getCommunesByPostalCode, type CommuneData } from "~/services/geo"
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -42,14 +50,20 @@ const props = defineProps<{
}>() }>()
const isLoading = ref(false) const isLoading = ref(false)
const submitted = ref(false)
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() =>
communes.value.map(c => ({ value: c.nom, label: c.nom }))
)
const emptyForm = (): AddressPayload => ({ const emptyForm = (): AddressPayload => ({
label: "",
street: "", street: "",
street2: null, street2: null,
postalCode: "", postalCode: "",
city: "", city: "",
countryCode: "", countryCode: "FR",
}) })
const form = reactive<AddressPayload>(emptyForm()) const form = reactive<AddressPayload>(emptyForm())
@@ -70,12 +84,11 @@ const backPath = computed(() => {
const hydrateForm = (address?: AddressPayload | null) => { const hydrateForm = (address?: AddressPayload | null) => {
const data = address ?? emptyForm() const data = address ?? emptyForm()
form.label = data.label ?? ""
form.street = data.street ?? "" form.street = data.street ?? ""
form.street2 = data.street2 ?? null form.street2 = data.street2 ?? null
form.postalCode = data.postalCode ?? "" form.postalCode = data.postalCode ?? ""
form.city = data.city ?? "" form.city = data.city ?? ""
form.countryCode = data.countryCode ?? "" form.countryCode = data.countryCode || "FR"
} }
watch( watch(
@@ -86,6 +99,41 @@ watch(
{ immediate: true } { immediate: true }
) )
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(
() => form.postalCode,
(cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) {
communes.value = []
form.city = ''
return
}
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
const previousCity = form.city
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) {
form.city = communes.value[0].nom
} else if (communes.value.some(c => c.nom === previousCity)) {
form.city = previousCity
} else {
form.city = ''
}
} finally {
isLoadingCities.value = false
}
}, 300)
}
}
)
const validateForm = () => { const validateForm = () => {
if (isLoading.value) return if (isLoading.value) return
emit("validate", {...form}) emit("validate", {...form})

View File

@@ -8,14 +8,15 @@
v-model="localWeight.weight" v-model="localWeight.weight"
:disabled="!isAdmin" :disabled="!isAdmin"
:min="0" :min="0"
:max="48000"
wrapper-class="flex-col" wrapper-class="flex-col"
required
/> />
<UiDateInput <UiDateInput
label="Date de pesée" label="Date de pesée"
v-model="localWeight.weighedAt" v-model="localWeight.weighedAt"
:disabled="!isAdmin" :disabled="!isAdmin"
required
/> />
<UiNumberInput <UiNumberInput
@@ -25,6 +26,7 @@
v-model="localWeight.dsd" v-model="localWeight.dsd"
:disabled="!isAdmin" :disabled="!isAdmin"
wrapper-class="flex-col" wrapper-class="flex-col"
required
/> />
</div> </div>
</form> </form>

View File

@@ -1,7 +1,9 @@
<template> <template>
<div <form
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS" v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
class="flex flex-col gap-16"> class="flex flex-col gap-16"
@submit.prevent="goNext"
>
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1> <h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des races réceptionnées</h1>
<div <div
class="flex flex-row gap-8 items-center w-full"> class="flex flex-row gap-8 items-center w-full">
@@ -31,15 +33,17 @@
/> />
</div> </div>
</div> </div>
<p class="text-red-500 text-sm" :class="showBovineError ? '' : 'invisible'">
Veuillez saisir au moins une race bovine.
</p>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="goNext"
>Valider >Valider
</UiButton> </UiButton>
</div> </div>
</div> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {BovineTypeData} from "~/services/dto/bovine-type-data"; import type {BovineTypeData} from "~/services/dto/bovine-type-data";
@@ -58,6 +62,7 @@ const toast = useToast()
const isLoadingBovineType = ref(false) const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([]) const bovineType = ref<BovineTypeData[]>([])
const receptionStore = useReceptionStore() const receptionStore = useReceptionStore()
const showBovineError = ref(false)
const bovineQuantities = reactive<Record<string, number | null>>({}) const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0) const otherQuantity = ref<number | null>(0)
const receptionId = computed(() => receptionStore.current?.id ?? null) const receptionId = computed(() => receptionStore.current?.id ?? null)
@@ -169,7 +174,13 @@ async function goNext() {
return return
} }
// @TODO Ajouter un composable pour le toaster qui gère les key i18n showBovineError.value = false
if (totalBovines.value === 0) {
showBovineError.value = true
return
}
if (totalBovines.value > 52) { if (totalBovines.value > 52) {
toast.error({ toast.error({
title: 'Erreur', title: 'Erreur',

View File

@@ -1,8 +1,7 @@
<template> <template>
<form @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"> <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> <h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Réception</h1>
<!-- Nom de l'utilisateur -->
<UiSelect <UiSelect
id="reception-user" id="reception-user"
v-model="form.userId" v-model="form.userId"
@@ -13,15 +12,15 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<!-- Date de réception -->
<UiDateInput <UiDateInput
id="reception-date" id="reception-date"
v-model="form.receptionDate" v-model="form.receptionDate"
label="Date de réception" label="Date de réception"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<!-- Type de réception -->
<UiSelect <UiSelect
id="reception-type" id="reception-type"
v-model="form.receptionTypeId" v-model="form.receptionTypeId"
@@ -31,8 +30,8 @@
label: type.label label: type.label
}))" }))"
wrapper-class="col-start-1 row-start-4" wrapper-class="col-start-1 row-start-4"
required
/> />
<!-- Fournisseur -->
<UiSelect <UiSelect
id="reception-supplier" id="reception-supplier"
v-model="form.supplierId" v-model="form.supplierId"
@@ -43,20 +42,17 @@
}))" }))"
:loading="isLoadingSuppliers" :loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-5" wrapper-class="col-start-1 row-start-5"
required
/> />
<!-- Adresse fournisseur -->
<UiSelect <UiSelect
id="reception-address" id="reception-address"
v-model="form.addressId" v-model="form.addressId"
label="Adresse" label="Adresse"
:options="supplierAddresses.map((address) => ({ :options="addressOptions"
value: String(address.id), :disabled="isLoadingSuppliers || ownerAddresses.length === 0"
label: address.fullAddress
}))"
:disabled="isLoadingSuppliers || supplierAddresses.length === 0"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<!-- Camion -->
<UiSelect <UiSelect
id="reception-truck" id="reception-truck"
v-model="form.truckId" v-model="form.truckId"
@@ -67,8 +63,8 @@
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-2"
required
/> />
<!-- Transporteur -->
<UiSelect <UiSelect
id="reception-carrier" id="reception-carrier"
v-model="form.carrierId" v-model="form.carrierId"
@@ -80,15 +76,15 @@
:loading="isLoadingCarriers" :loading="isLoadingCarriers"
select-class="h-[34px]" select-class="h-[34px]"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<!-- Plaque d'immatriculation -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput <UiLicensePlateInput
v-model="form.licensePlate" v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate" v-model:allowAny="allowAnyLicensePlate"
required
/> />
</div> </div>
<!-- Immatriculation (LIOT) -->
<UiSelect <UiSelect
v-if="isLiotCarrier" v-if="isLiotCarrier"
id="reception-vehicle" id="reception-vehicle"
@@ -101,8 +97,8 @@
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0" :disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4 h-[64px]" wrapper-class="col-start-2 row-start-4 h-[64px]"
required
/> />
<!-- Chauffeur (LIOT) -->
<UiSelect <UiSelect
id="reception-driver" id="reception-driver"
v-model="form.driverId" v-model="form.driverId"
@@ -114,42 +110,39 @@
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
v-if="isLiotCarrier" v-if="isLiotCarrier"
wrapper-class="col-start-2 row-start-5" wrapper-class="col-start-2 row-start-5"
required
/> />
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" 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 >Valider
</UiButton> </UiButton>
</div> </div>
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {useReceptionStore} from '~/stores/reception' import { useReceptionStore } from '~/stores/reception'
import type {ReceptionTypeData} from '~/services/dto/reception-type-data' import { useFormDataLoading } from '~/composables/useFormDataLoading'
import {getReceptionTypeList} from '~/services/reception-type' import { useLiotHandling } from '~/composables/useLiotHandling'
import type {UserData} from '~/services/dto/user-data' import { useAddressSync } from '~/composables/useAddressSync'
import {getUsers} from '~/services/auth' import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import {useAuthStore} from '~/stores/auth' import { getReceptionTypeList } from '~/services/reception-type'
import type {SupplierData} from '~/services/dto/supplier-data' import type { SupplierData } from '~/services/dto/supplier-data'
import {getSupplierList} from '~/services/supplier' import { getSupplierList } from '~/services/supplier'
import type {TruckData} from '~/services/dto/truck-data' import { RECEPTION_TYPE_CODES } from '~/utils/constants'
import {getTruckList} from '~/services/truck' import { deleteReceptionBovine, getReceptionBovineList } from '~/services/reception-bovine'
import type {CarrierData} from '~/services/dto/carrier-data' import type { ReceptionFormData } from '~/services/dto/reception-data'
import {getCarrierList} from '~/services/carrier'
import type {DriverData} from '~/services/dto/driver-data'
import {getDriverList} from '~/services/driver'
import type {VehicleData} from '~/services/dto/vehicle-data'
import {getVehicleList} from '~/services/vehicle'
import {RECEPTION_TYPE_CODES, SUPPLIER_CODE} from "~/utils/constants";
import {deleteReceptionBovine, getReceptionBovineList} from "~/services/reception-bovine";
import type {ReceptionFormData} from "~/services/dto/reception-data";
const router = useRouter() const router = useRouter()
const receptionStore = useReceptionStore() const receptionStore = useReceptionStore()
const isHydrating = ref(false)
const submitted = ref(false)
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<ReceptionFormData>({ const form = reactive<ReceptionFormData>({
licensePlate: '', licensePlate: '',
receptionDate: new Date().toISOString().slice(0, 10), receptionDate: new Date().toISOString().slice(0, 10),
@@ -162,62 +155,27 @@ const form = reactive<ReceptionFormData>({
driverId: '', driverId: '',
vehicleId: '' vehicleId: ''
}) })
const allowAnyLicensePlate = ref(false)
const receptionTypes = ref<ReceptionTypeData[]>([]) const receptionTypes = ref<ReceptionTypeData[]>([])
const users = ref<UserData[]>([])
const isLoadingUsers = ref(false)
const suppliers = ref<SupplierData[]>([]) const suppliers = ref<SupplierData[]>([])
const isLoadingSuppliers = ref(false) const isLoadingSuppliers = ref(false)
const trucks = ref<TruckData[]>([])
const isLoadingTrucks = ref(false)
const carriers = ref<CarrierData[]>([])
const isLoadingCarriers = ref(false)
const drivers = ref<DriverData[]>([])
const isLoadingDrivers = ref(false)
const vehicles = ref<VehicleData[]>([])
const isLoadingVehicles = ref(false)
const authStore = useAuthStore()
// Empêche les watchers de reset des champs pendant le remplissage initial
const isHydrating = ref(false)
// Transporteur sélectionné dans le formulaire const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
const selectedCarrier = computed(() => useFormDataLoading(form)
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
) const {
// Indique si le transporteur est LIOT isLiotCarrier, filteredDrivers, filteredVehicles,
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT) isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
// Adresses disponibles pour le fournisseur sélectionné loadDrivers, loadVehicles
const supplierAddresses = computed(() => { } = useLiotHandling(form, carriers, isHydrating)
const supplierId = Number(form.supplierId)
if (!Number.isFinite(supplierId)) { const supplierIdRef = computed(() => form.supplierId)
return [] const { ownerAddresses, addressOptions } = useAddressSync(form, supplierIdRef, suppliers)
}
return suppliers.value.find((supplier) => supplier.id === supplierId)?.addresses ?? []
})
// Chauffeurs filtrés par transporteur (LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
// Véhicules filtrés par transporteur + type de camion
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
const selectedReceptionType = computed(() => const selectedReceptionType = computed(() =>
receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null receptionTypes.value.find((type) => String(type.id) === form.receptionTypeId) ?? null
) )
// Supprime les données bovines si on change de type de réception
const clearReceptionBovines = async (receptionIri: string) => { const clearReceptionBovines = async (receptionIri: string) => {
const existing = await getReceptionBovineList(receptionIri) const existing = await getReceptionBovineList(receptionIri)
for (const selection of existing) { for (const selection of existing) {
@@ -225,50 +183,6 @@ const clearReceptionBovines = async (receptionIri: string) => {
} }
} }
// Hydrate le formulaire depuis la réception en cours
watch(
() => receptionStore.current,
(reception) => {
isHydrating.value = true
form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10)
form.receptionTypeId = reception?.receptionType?.id
? String(reception.receptionType.id)
: ''
form.userId = reception?.user?.id
? String(reception.user.id)
: form.userId
form.supplierId = reception?.supplier?.id
? String(reception.supplier.id)
: ''
form.addressId = reception?.address?.id
? String(reception.address.id)
: ''
form.truckId = reception?.truck?.id
? String(reception.truck.id)
: ''
form.carrierId = reception?.carrier?.id
? String(reception.carrier.id)
: ''
form.driverId = reception?.driver?.id
? String(reception.driver.id)
: ''
isHydrating.value = false
},
{immediate: true}
)
// Charge la liste des users pour le select
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
// Charge la liste des fournisseurs pour le select
const loadSuppliers = async () => { const loadSuppliers = async () => {
isLoadingSuppliers.value = true isLoadingSuppliers.value = true
try { try {
@@ -278,186 +192,33 @@ const loadSuppliers = async () => {
} }
} }
// Charge la liste des camions pour le select watch(
const loadTrucks = async () => { () => receptionStore.current,
isLoadingTrucks.value = true (reception) => {
try { isHydrating.value = true
trucks.value = await getTruckList() form.licensePlate = reception?.licensePlate ?? ''
} finally { form.receptionDate = reception?.receptionDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
isLoadingTrucks.value = false form.receptionTypeId = reception?.receptionType?.id ? String(reception.receptionType.id) : ''
} form.userId = reception?.user?.id ? String(reception.user.id) : form.userId
} form.supplierId = reception?.supplier?.id ? String(reception.supplier.id) : ''
form.addressId = reception?.address?.id ? String(reception.address.id) : ''
form.truckId = reception?.truck?.id ? String(reception.truck.id) : ''
form.carrierId = reception?.carrier?.id ? String(reception.carrier.id) : ''
form.driverId = reception?.driver?.id ? String(reception.driver.id) : ''
isHydrating.value = false
},
{ immediate: true }
)
// Charge la liste des transporteurs pour le select
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
// Charge la liste des chauffeurs pour le select
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
// Charge la liste des véhicules pour le select
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
// On met le user connecté par défaut dans le select
const setDefaultUser = () => {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
// On récupère toutes les données des selects au chargement du composant
onMounted(async () => { onMounted(async () => {
receptionTypes.value = await getReceptionTypeList() receptionTypes.value = await getReceptionTypeList()
await loadUsers()
await loadSuppliers() await loadSuppliers()
await loadTrucks() await loadCommonData()
await loadCarriers()
await loadDrivers() await loadDrivers()
await loadVehicles() await loadVehicles()
await authStore.ensureSession()
setDefaultUser()
}) })
// Ajuste driver/vehicle quand le transporteur change (logique LIOT) const buildPayload = () => {
watch(
() => [form.supplierId, form.addressId, suppliers.value],
() => {
if (!form.supplierId) {
form.addressId = ''
return
}
if (!form.addressId && supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
return
}
if (!form.addressId) {
return
}
const matches = supplierAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (supplierAddresses.value.length === 1) {
form.addressId = String(supplierAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{immediate: true}
)
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
watch(
() => form.carrierId,
() => {
if (isHydrating.value) {
return
}
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
},
{immediate: true}
)
// Récupère la plaque depuis le véhicule choisi (LIOT)
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{immediate: true}
)
// Auto-renseigne le véhicule si la plaque correspond (LIOT)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
// Valide le formulaire et crée/met à jour la réception
async function validate() {
const normalizedLicensePlate = form.licensePlate.trim() const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim() const normalizedReceptionDate = form.receptionDate.trim()
const normalizedReceptionTypeId = form.receptionTypeId.trim() const normalizedReceptionTypeId = form.receptionTypeId.trim()
@@ -467,29 +228,16 @@ async function validate() {
const normalizedTruckId = form.truckId.trim() const normalizedTruckId = form.truckId.trim()
const normalizedCarrierId = form.carrierId.trim() const normalizedCarrierId = form.carrierId.trim()
const normalizedDriverId = form.driverId.trim() const normalizedDriverId = form.driverId.trim()
const receptionTypeIri = normalizedReceptionTypeId
? `/api/reception_types/${normalizedReceptionTypeId}`
: null
const userIri = normalizedUserId
? `/api/users/${normalizedUserId}`
: null
const supplierIri = normalizedSupplierId
? `/api/suppliers/${normalizedSupplierId}`
: null
const addressIri = normalizedAddressId
? `/api/addresses/${normalizedAddressId}`
: null
const truckIri = normalizedTruckId
? `/api/trucks/${normalizedTruckId}`
: null
const carrierIri = normalizedCarrierId
? `/api/carriers/${normalizedCarrierId}`
: null
const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}`
: null
const basePayload = { const receptionTypeIri = normalizedReceptionTypeId ? `/api/reception_types/${normalizedReceptionTypeId}` : null
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
const supplierIri = normalizedSupplierId ? `/api/suppliers/${normalizedSupplierId}` : null
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
return {
licensePlate: normalizedLicensePlate, licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate, receptionDate: normalizedReceptionDate,
receptionType: receptionTypeIri, receptionType: receptionTypeIri,
@@ -497,13 +245,35 @@ async function validate() {
supplier: supplierIri, supplier: supplierIri,
address: addressIri, address: addressIri,
truck: truckIri, truck: truckIri,
carrier: carrierIri carrier: carrierIri,
...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {})
} }
}
const payload = { const saveDraft = async () => {
...basePayload, const payload = buildPayload()
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {}) 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) { if (!receptionStore.current) {
const created = await receptionStore.createReception({ const created = await receptionStore.createReception({
@@ -532,5 +302,4 @@ async function validate() {
...payload ...payload
}) })
} }
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col items-center gap-16"> <form :class="['flex flex-col items-center gap-16', { submitted }]" @submit.prevent="goNext">
<div <div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES" v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
class="flex flex-col gap-16 items-center w-full"> class="flex flex-col gap-16 items-center w-full">
@@ -10,6 +10,7 @@
label="Type de marchandises" label="Type de marchandises"
:options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))" :options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))"
wrapper-class="w-[550px]" wrapper-class="w-[550px]"
required
/> />
<div <div
v-if="selectedMerchandiseTypeId && isAutres" v-if="selectedMerchandiseTypeId && isAutres"
@@ -21,24 +22,30 @@
label="Préciser" label="Préciser"
placeholder="Précisions complémentaires" placeholder="Précisions complémentaires"
:maxlength="255" :maxlength="255"
required
/> />
</div> </div>
<div <div
v-if="selectedMerchandiseTypeId && !isGranule" v-if="selectedMerchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-between" class="flex flex-col gap-4 w-[550px]"
> >
<div <div class="flex gap-4 justify-between">
v-for="building in buildings" <div
:key="building.id" v-for="building in buildings"
> :key="building.id"
<UiCheckbox >
v-model="selectedBuildingIds" <UiCheckbox
:value="String(building.id)" v-model="selectedBuildingIds"
:label="building.label" :value="String(building.id)"
label-class="text-xl" :label="building.label"
/> label-class="text-xl"
/>
</div>
</div> </div>
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
Veuillez sélectionner au moins un bâtiment.
</p>
</div> </div>
<div <div
@@ -62,17 +69,20 @@
</div> </div>
</div> </div>
</div> </div>
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
Veuillez sélectionner au moins un bâtiment.
</p>
</div> </div>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end" class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="goNext" @click="submitted = true"
>Valider >Valider
</UiButton> </UiButton>
</div> </div>
</div> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -100,6 +110,9 @@ const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([]) const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({}) const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('') const merchandiseDetail = ref('')
const submitted = ref(false)
const showBuildingError = ref(false)
const showPelletBuildingError = ref(false)
// Extrait l'ID d'une relation depuis un IRI ou un objet complet. // Extrait l'ID d'une relation depuis un IRI ou un objet complet.
const getRelationId = (value: unknown): string | null => { const getRelationId = (value: unknown): string | null => {
@@ -182,6 +195,23 @@ async function goNext() {
return return
} }
showBuildingError.value = false
showPelletBuildingError.value = false
if (!isGranule.value && !isAutres.value && selectedBuildingIds.value.length === 0) {
showBuildingError.value = true
return
}
if (isGranule.value) {
const hasAnyPelletBuilding = Object.values(selectedPelletBuildingIds.value)
.some((ids) => ids.length > 0)
if (!hasAnyPelletBuilding) {
showPelletBuildingError.value = true
return
}
}
const nextStep = receptionStore.current.currentStep + 1 const nextStep = receptionStore.current.currentStep + 1
const receptionIri = `/api/receptions/${receptionStore.current.id}` const receptionIri = `/api/receptions/${receptionStore.current.id}`

View File

@@ -1,101 +0,0 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
<p class="text-primary-500 uppercase text-2xl text-primary-500 mt-2">Pont-bascule connecté</p>
<div
v-if="showLoadingBox"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<UiLoadingDots />
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</UiButton>
<UiButton
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</UiButton>
</div>
</template>
<script setup lang="ts">
import {computed, onMounted} from 'vue'
import { storeToRefs } from 'pinia'
import { useWeighing } from '~/composables/useWeighing'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
import { useReceptionStore } from '~/stores/reception'
const props = defineProps<{
mode: 'gross' | 'tare'
}>()
const router = useRouter()
const receptionStore = useReceptionStore()
const { current: storeReception } = storeToRefs(receptionStore)
const { printPdf } = usePdfPrinter()
const {
displayWeight,
title,
showLoadingBox,
fetchWeight,
saveWeight
} = useWeighing({
mode: props.mode,
reception: storeReception,
updateReception: receptionStore.updateReception,
loadReception: receptionStore.loadReception
})
// Affiche le bouton de génération du bon à l'étape tare
const showGenerateReceipt = computed(
() => props.mode === 'tare' && displayWeight.value !== null
)
// Génère le bon de réception, puis clôture la réception
const printReceipt = async () => {
if (!import.meta.client || !receptionStore.current) {
return
}
await saveWeight()
const reception = receptionStore.current
const filename = `${reception.identificationNumber ?? reception.id}_${reception.supplier?.name ?? 'fournisseur'}_${reception.licensePlate ?? 'immat'}.pdf`
await printPdf(`/receptions/${reception.id}/receipt`, filename)
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
await new Promise((resolve) => setTimeout(resolve, 600))
const result = await receptionStore.updateReception(receptionStore.current.id, {
isValid: true
})
if (!result) {
return
}
receptionStore.clearCurrent()
await router.push('/')
}
// Récupère le poids dès l'arrivée sur l'écran
onMounted(() => {
if (displayWeight.value === null) {
fetchWeight()
}
})
</script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<form> <form>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="w-full col-start-1 row-start-1"> <div class="w-full relative grid grid-cols-[1fr_200px]">
<UiRadioGroup <UiRadioGroup
id="merchandise-type" id="merchandise-type"
v-model="selectedMerchandiseTypeId" v-model="selectedMerchandiseTypeId"
@@ -13,15 +13,25 @@
input-class="accent-primary-700 focus:ring-primary-700" input-class="accent-primary-700 focus:ring-primary-700"
option-label-class="uppercase" option-label-class="uppercase"
wrapper-class="w-full uppercase" wrapper-class="w-full uppercase"
group-class="grid grid-cols-[336px_336px_355px_200px] w-[160px_160px_200px_180px] mt-9 mb-7" group-class="grid grid-cols-4 mt-9 mb-7"
:disabled="!isAdmin" :disabled="!isAdmin"
/> />
<UiTextInput
v-if="isAutres"
id="merchandise-detail"
:disabled="!isAdmin"
v-model="merchandiseDetail"
placeholder="Préciser"
:maxlength="255"
wrapper-class="w-[200px] mt-12 mb-7"
/>
</div> </div>
<div class="w-full grid grid-cols-[3fr_1fr] gap-12 col-start-2 row-start-1"> <div
<div v-if="selectedMerchandiseTypeId && !isGranule"
v-if="selectedMerchandiseTypeId && !isGranule" class="w-full grid grid-cols-[1fr_200px]"
class="flex gap-[218px]" >
<div class="grid grid-cols-4 gap-6"
> >
<div <div
v-for="building in buildings" v-for="building in buildings"
@@ -33,31 +43,17 @@
:label="building.label" :label="building.label"
:disabled="!isAdmin" :disabled="!isAdmin"
input-class="accent-primary-700 focus:ring-primary-700" input-class="accent-primary-700 focus:ring-primary-700"
label-class="text-xl uppercase" label-class="uppercase"
/> />
</div> </div>
</div> </div>
<div
v-if="selectedMerchandiseTypeId && isAutres"
class="flex flex-col justify-self-end max-w-[182px]"
>
<UiTextInput
id="merchandise-detail"
:disabled="!isAdmin"
v-model="merchandiseDetail"
placeholder="Préciser"
:maxlength="255"
class="h-6"
/>
</div>
</div> </div>
<div <div
v-if="selectedMerchandiseTypeId && isGranule" v-if="selectedMerchandiseTypeId && isGranule"
class="flex flex-col gap-10 w-full col-start-2 row-start-1" class="grid grid-cols-[1fr_200px] w-full col-start-2 row-start-1"
> >
<div class="grid grid-cols-1 md:grid-cols-[max-content_max-content_max-content_max-content] justify-between"> <div class="grid grid-cols-4 gap-6 justify-between">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4"> <div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="mb-1 font-medium uppercase">{{ type.label }}</p> <p class="mb-1 font-medium uppercase">{{ type.label }}</p>
<div <div

View File

@@ -1,8 +1,7 @@
<template> <template>
<form @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"> <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> <h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
<!-- Nom de l'utilisateur -->
<UiSelect <UiSelect
id="shipment-user" id="shipment-user"
v-model="form.userId" v-model="form.userId"
@@ -13,15 +12,15 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<!-- Date de l'éxpedition -->
<UiDateInput <UiDateInput
id="shipment-date" id="shipment-date"
v-model="form.shipmentDate" v-model="form.shipmentDate"
label="Date du jour" label="Date du jour"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<!-- Type d'expédition -->
<div class="col-start-1 row-start-4 h-[64px]"> <div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]"> <div class="flex w-full items-end gap-[104px]">
<UiRadioGroup <UiRadioGroup
@@ -36,6 +35,7 @@
value: String(type.id), value: String(type.id),
label: type.label label: type.label
}))" }))"
required
/> />
<UiNumberInput <UiNumberInput
id="shipment-type-quantity" id="shipment-type-quantity"
@@ -47,7 +47,6 @@
/> />
</div> </div>
</div> </div>
<!-- Client -->
<UiSelect <UiSelect
id="shipment-customer" id="shipment-customer"
v-model="form.customerId" v-model="form.customerId"
@@ -58,17 +57,17 @@
}))" }))"
:loading="isLoadingCustomers" :loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5" wrapper-class="col-start-1 row-start-5"
required
/> />
<!-- Adresse du client -->
<UiSelect <UiSelect
id="shipment-address" id="shipment-address"
v-model="form.addressId" v-model="form.addressId"
:options="customerAddressOptions" :options="addressOptions"
:disabled="isLoadingCustomers || customerAddresses.length === 0" :disabled="isLoadingCustomers || ownerAddresses.length === 0"
label="Adresse" label="Adresse"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<!-- Camion -->
<UiSelect <UiSelect
id="shipment-truck" id="shipment-truck"
v-model="form.truckId" v-model="form.truckId"
@@ -79,8 +78,8 @@
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-2"
required
/> />
<!-- Transporteur -->
<UiSelect <UiSelect
id="shipment-carrier" id="shipment-carrier"
v-model="form.carrierId" v-model="form.carrierId"
@@ -90,15 +89,15 @@
label: carrier.name label: carrier.name
}))" }))"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<!-- Plaque d'immatriculation (hors LIOT) -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput <UiLicensePlateInput
v-model="form.licensePlate" v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate" v-model:allowAny="allowAnyLicensePlate"
required
/> />
</div> </div>
<!-- Immatriculation (LIOT) -->
<UiSelect <UiSelect
v-if="isLiotCarrier" v-if="isLiotCarrier"
id="shipment-vehicle" id="shipment-vehicle"
@@ -111,8 +110,8 @@
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0" :disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-4"
required
/> />
<!-- Chauffeur (LIOT) -->
<UiSelect <UiSelect
id="shipment-driver" id="shipment-driver"
v-model="form.driverId" v-model="form.driverId"
@@ -124,67 +123,36 @@
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-5" wrapper-class="col-start-2 row-start-5"
v-if="isLiotCarrier" v-if="isLiotCarrier"
required
/> />
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
type="submit" type="submit"
class="text-xl mb-16 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 >Valider
</UiButton> </UiButton>
</div> </div>
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useFormDataLoading } from '~/composables/useFormDataLoading'
import { useLiotHandling } from '~/composables/useLiotHandling'
import { useAddressSync } from '~/composables/useAddressSync'
import type { CustomerData } from '~/services/dto/customer-data'
import { getCustomerList } from '~/services/customer'
import type { ShipmentFormData } from '~/services/dto/shipment-data'
import { useShipmentStore } from '~/stores/shipment'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import { getShipmentTypeList } from '~/services/shipment-type'
import type {UserData} from '~/services/dto/user-data'
import type {CustomerData} from '~/services/dto/customer-data'
import type {TruckData} from '~/services/dto/truck-data'
import type {CarrierData} from '~/services/dto/carrier-data'
import type {DriverData} from '~/services/dto/driver-data'
import type {VehicleData} from '~/services/dto/vehicle-data'
import type {AddressData} from '~/services/dto/address-data'
import {getUsers} from '~/services/auth'
import {getCustomerList} from '~/services/customer'
import {getTruckList} from '~/services/truck'
import {getCarrierList} from '~/services/carrier'
import {getVehicleList} from '~/services/vehicle'
import {getDriverList} from '~/services/driver'
import type {ShipmentFormData} from '~/services/dto/shipment-data'
import {SUPPLIER_CODE} from "~/utils/constants"
import {useAuthStore} from '~/stores/auth'
import {useShipmentStore} from '~/stores/shipment'
import {computed, reactive, ref, watch, onMounted} from 'vue'
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
import {getShipmentTypeList} from "~/services/shipment-type";
const users = ref<UserData[]>([])
const customers = ref<CustomerData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const isLoadingUsers = ref(false)
const isLoadingShipmentTypes = ref(false)
const isLoadingCustomers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const isHydrating = ref(false)
const isLoadingVehicles = ref(false)
const allowAnyLicensePlate = ref(false)
const isLoadingDrivers = ref(false)
const authStore = useAuthStore()
const shipmentStore = useShipmentStore()
const router = useRouter() const router = useRouter()
const bovineShipment = ref<ShipmentTypeData[]>([]) const shipmentStore = useShipmentStore()
const selectedShipmentTypeId = ref('') const isHydrating = ref(false)
const shipmentQuantity = ref<number | null>(0) const submitted = ref(false)
// Transporteur sélectionné dans le formulaire const formRef = ref<HTMLFormElement | null>(null)
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const form = reactive<ShipmentFormData>({ const form = reactive<ShipmentFormData>({
userId: '', userId: '',
@@ -197,58 +165,24 @@ const form = reactive<ShipmentFormData>({
vehicleId: '', vehicleId: '',
licensePlate: '', licensePlate: '',
}) })
// Adresses liées au client sélectionné
const customerAddresses = computed<AddressData[]>(() => {
const customerId = Number(form.customerId)
if (!Number.isFinite(customerId)) {
return []
}
return customers.value.find((customer) => customer.id === customerId)?.addresses ?? []
})
// Options pour le select des adresses du client
const customerAddressOptions = computed(() =>
customerAddresses.value
.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
// Chauffeurs liés au transporteur sélectionné (LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) {
return []
}
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
// Véhicules liés au transporteur + camion sélectionnés (LIOT)
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) {
return []
}
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
// Chargement des données pour les selects
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadShipmentType = async () => { const customers = ref<CustomerData[]>([])
isLoadingShipmentTypes.value = true const isLoadingCustomers = ref(false)
try { const bovineShipment = ref<ShipmentTypeData[]>([])
bovineShipment.value = await getShipmentTypeList() const selectedShipmentTypeId = ref('')
} finally { const shipmentQuantity = ref<number | null>(0)
isLoadingShipmentTypes.value = false
} const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
} useFormDataLoading(form)
const {
isLiotCarrier, filteredDrivers, filteredVehicles,
isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
loadDrivers, loadVehicles
} = useLiotHandling(form, carriers, isHydrating)
const customerIdRef = computed(() => form.customerId)
const { ownerAddresses, addressOptions } = useAddressSync(form, customerIdRef, customers)
const loadCustomers = async () => { const loadCustomers = async () => {
isLoadingCustomers.value = true isLoadingCustomers.value = true
@@ -257,212 +191,51 @@ const loadCustomers = async () => {
} finally { } finally {
isLoadingCustomers.value = false isLoadingCustomers.value = false
} }
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
// On met le user connecté par défaut dans le select
const setDefaultUser = () => {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
// Chargement initial des données
onMounted(async () => {
await loadShipmentType()
await loadUsers()
await loadCustomers()
await loadTrucks()
await loadCarriers()
await loadVehicles()
await loadDrivers()
await authStore.ensureSession()
setDefaultUser()
})
// Hydrate le formulaire depuis l'expédition en cours
watch( watch(
() => shipmentStore.current, () => shipmentStore.current,
(shipment) => { (shipment) => {
isHydrating.value = true isHydrating.value = true
form.licensePlate = shipment?.licensePlate ?? '' form.licensePlate = shipment?.licensePlate ?? ''
form.shipmentDate = shipment?.shipmentDate ?? new Date().toISOString().slice(0, 10) form.shipmentDate = shipment?.shipmentDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
form.userId = shipment?.user?.id ? String(shipment.user.id) : form.userId = shipment?.user?.id ? String(shipment.user.id) : form.userId
form.userId form.customerId = shipment?.customer?.id ? String(shipment.customer.id) : ''
form.customerId = shipment?.customer?.id ?
String(shipment.customer.id) : ''
form.addressId = shipment?.address?.id ? String(shipment.address.id) : '' form.addressId = shipment?.address?.id ? String(shipment.address.id) : ''
form.truckId = shipment?.truck?.id ? String(shipment.truck.id) : '' form.truckId = shipment?.truck?.id ? String(shipment.truck.id) : ''
form.carrierId = shipment?.carrier?.id ? String(shipment.carrier.id) : '' form.carrierId = shipment?.carrier?.id ? String(shipment.carrier.id) : ''
form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : '' form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : '' form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : ''
selectedShipmentTypeId.value = shipment?.shipmentType?.id ? String(shipment.shipmentType.id) : ''
selectedShipmentTypeId.value = shipment?.shipmentType?.id
? String(shipment.shipmentType.id)
: ''
shipmentQuantity.value = shipment?.nbBovinSend ?? 0 shipmentQuantity.value = shipment?.nbBovinSend ?? 0
isHydrating.value = false isHydrating.value = false
}, },
{immediate: true} { immediate: true }
)
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.customerId, form.addressId, customers.value],
() => {
if (!form.customerId) {
form.addressId = ''
return
}
if (!form.addressId && customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
return
}
if (!form.addressId) {
return
}
const matches = customerAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (customerAddresses.value.length === 1) {
form.addressId = String(customerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{immediate: true}
)
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
const applyLiotDefaults = () => {
if (isHydrating.value) {
return
}
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
}
watch(
() => form.carrierId,
() => {
applyLiotDefaults()
},
{immediate: true}
) )
// Extra watcher for LIOT defaults after hydration
watch( watch(
() => isHydrating.value, () => isHydrating.value,
(value) => { (value) => {
if (!value) { if (!value && isLiotCarrier.value) {
applyLiotDefaults() if (filteredDrivers.value.length === 1 && !form.driverId) {
} form.driverId = String(filteredDrivers.value[0].id)
} }
) if (filteredVehicles.value.length === 1 && !form.vehicleId) {
// Récupère la plaque depuis le véhicule choisi (LIOT) form.vehicleId = String(filteredVehicles.value[0].id)
watch( }
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{immediate: true}
)
// Auto-renseigne le véhicule si la plaque correspond (LIOT)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licensePlate, form.carrierId, form.vehicleId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
} }
} }
) )
onMounted(async () => {
bovineShipment.value = await getShipmentTypeList()
await loadCustomers()
await loadCommonData()
await loadVehicles()
await loadDrivers()
})
const buildPayload = () => { const buildPayload = () => {
const normalizedLicensePlate = form.licensePlate.trim() const normalizedLicensePlate = form.licensePlate.trim()
const normalizedShipmentDate = form.shipmentDate.trim() const normalizedShipmentDate = form.shipmentDate.trim()
@@ -472,32 +245,18 @@ const buildPayload = () => {
const normalizedDriverId = form.driverId.trim() const normalizedDriverId = form.driverId.trim()
const normalizedUserId = form.userId.trim() const normalizedUserId = form.userId.trim()
const normalizedAddressId = form.addressId.trim() const normalizedAddressId = form.addressId.trim()
const customerIri = normalizedCustomerId
? `/api/customers/${normalizedCustomerId}` const customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null
: null const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const truckIri = normalizedTruckId const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
? `/api/trucks/${normalizedTruckId}` const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
: null const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
const carrierIri = normalizedCarrierId const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
? `/api/carriers/${normalizedCarrierId}`
: null
const userIri = normalizedUserId
? `/api/users/${normalizedUserId}`
: null
const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}`
: null
const addressIri = normalizedAddressId
? `/api/addresses/${normalizedAddressId}`
: null
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim() const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
const shipmentTypeIri = normalizedShipmentTypeId const shipmentTypeIri = normalizedShipmentTypeId ? `/api/shipment_types/${normalizedShipmentTypeId}` : null
? `/api/shipment_types/${normalizedShipmentTypeId}`
: null
const rawQuantity = Number(shipmentQuantity.value ?? 0) const rawQuantity = Number(shipmentQuantity.value ?? 0)
const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0, const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0, Math.trunc(rawQuantity)) : 0
Math.trunc(rawQuantity)) : 0
return { return {
licensePlate: normalizedLicensePlate, licensePlate: normalizedLicensePlate,
@@ -522,15 +281,19 @@ const saveDraft = async () => {
}) })
return return
} }
await shipmentStore.updateShipment(shipmentStore.current.id, { await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: shipmentStore.current.currentStep, currentStep: shipmentStore.current.currentStep,
...payload ...payload
}) })
} }
defineExpose({saveDraft}) const validateFields = () => {
// Valide le formulaire et crée/met à jour l'expédition submitted.value = true
return formRef.value?.reportValidity() ?? false
}
defineExpose({ saveDraft, validateFields })
const validate = async () => { const validate = async () => {
const payload = buildPayload() const payload = buildPayload()
if (!shipmentStore.current) { if (!shipmentStore.current) {

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="flex flex-col items-center gap-[118px]"> <div class="flex flex-col items-center gap-[150px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1> <h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1>
<div <div
class="w-full flex flex-col items-center justify-center"> class="w-full flex flex-col items-center justify-center">

View File

@@ -1,101 +0,0 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<!--@TODO Voir comment faire pour savoir si le pont-bascule et bien connecté + ajouter un icon comme sur la maquette-->
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<div
v-if="showLoadingBox"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<UiLoadingDots />
</div>
<div v-else-if="displayWeight !== null" class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-primary-500 h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</UiButton>
<UiButton
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</UiButton>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useWeighingShipment } from '~/composables/useWeighing'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
import { useShipmentStore } from '~/stores/shipment'
const props = defineProps<{
mode: 'gross' | 'tare'
}>()
const router = useRouter()
const shipmentStore = useShipmentStore()
const { current: storeShipment } = storeToRefs(shipmentStore)
const { printPdf } = usePdfPrinter()
const {
displayWeight,
title,
showLoadingBox,
fetchWeight,
saveWeight
} = useWeighingShipment({
modeShipment: props.mode,
shipment: storeShipment,
updateShipment: shipmentStore.updateShipment,
loadShipment: shipmentStore.loadShipment
})
// Affiche le bouton de génération du bon à l'étape tare
const showGenerateReceipt = computed(
() => props.mode === 'tare' && displayWeight.value !== null
)
// Génère le bon d'expédition, puis clôture l'expédition
const printReceipt = async () => {
if (!import.meta.client || !shipmentStore.current) {
return
}
await saveWeight()
const shipment = shipmentStore.current
const filename = `${shipment.identificationNumber ?? shipment.id}_${shipment.customer?.label ?? 'client'}_${shipment.licensePlate ?? 'immat'}.pdf`
await printPdf(`/shipments/${shipment.id}/receipt`, filename)
// Laisse le temps a la boite de dialogue d'impression de s'ouvrir.
await new Promise((resolve) => setTimeout(resolve, 600))
const result = await shipmentStore.updateShipment(shipmentStore.current.id, {
isValid: true
})
if (!result) {
return
}
shipmentStore.clearCurrent()
await router.push('/')
}
// Récupère le poids dès l'arrivée sur l'écran
onMounted(() => {
if (displayWeight.value === null) {
fetchWeight()
}
})
</script>

View File

@@ -9,6 +9,7 @@
type="text" type="text"
:maxlength="maxLength" :maxlength="maxLength"
:placeholder="placeholderText" :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-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
@input="handleInput" @input="handleInput"
/> />
@@ -32,12 +33,14 @@ type Props = {
allowAny?: boolean allowAny?: boolean
label?: string label?: string
id?: string id?: string
required?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
allowAny: false, allowAny: false,
label: 'Immatriculation', label: 'Immatriculation',
id: 'license-plate' id: 'license-plate',
required: false
}) })
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -0,0 +1,57 @@
<template>
<template v-if="!isLiotCarrier">
<div :class="wrapperClass">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
/>
</div>
</template>
<template v-if="isLiotCarrier">
<UiSelect
:id="`${idPrefix}-vehicle`"
v-model="form.vehicleId"
label="Immatriculation"
:options="filteredVehicles.map((vehicle) => ({
value: String(vehicle.id),
label: vehicle.plate
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
:wrapper-class="wrapperClass"
/>
<UiSelect
:id="`${idPrefix}-driver`"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
:wrapper-class="driverWrapperClass"
/>
</template>
</template>
<script setup lang="ts">
import type { DriverData } from '~/services/dto/driver-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
defineProps<{
idPrefix: string
form: { licensePlate: string; vehicleId: string; driverId: string }
isLiotCarrier: boolean
allowAnyLicensePlate: boolean
filteredVehicles: VehicleData[]
filteredDrivers: DriverData[]
isLoadingVehicles: boolean
isLoadingDrivers: boolean
wrapperClass?: string
driverWrapperClass?: string
}>()
defineEmits<{
'update:allowAnyLicensePlate': [value: boolean]
}>()
</script>

View File

@@ -0,0 +1,72 @@
<template>
<div class="flex items-center justify-between">
<div class="flex items-center 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">{{ title }}</h1>
</div>
</div>
<div class="px-[86px]">
<div class="mt-6 border border-slate-200 mb-16">
<div
class="grid gap-4 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">{{ col.label }}</div>
<div v-if="showActions">Actions</div>
</div>
<div
v-for="item in items"
:key="item.id"
class="grid gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
:style="{ gridTemplateColumns: gridCols }"
role="button"
tabindex="0"
@click="goToItem(item.id)"
@keydown.enter="goToItem(item.id)"
>
<div v-for="col in columns" :key="col.key">
<slot :name="`cell-${col.key}`" :item="item">
{{ getNestedValue(item, col.key) }}
</slot>
</div>
<div v-if="showActions" @click.stop>
<slot name="actions" :item="item" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Column {
key: string
label: string
}
const props = withDefaults(defineProps<{
title: string
columns: Column[]
items: any[]
routePrefix: string
showActions?: boolean
}>(), {
showActions: false
})
const router = useRouter()
const gridCols = computed(() => {
const dataCols = props.columns.map(() => '1fr').join(' ')
return props.showActions ? `${dataCols} 60px` : dataCols
})
const goToItem = (id: number) => {
router.push(`${props.routePrefix}/${id}`)
}
const getNestedValue = (obj: any, path: string): string => {
const value = path.split('.').reduce((acc, key) => acc?.[key], obj)
return value || '—'
}
</script>

View File

@@ -0,0 +1,81 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">{{ title }}</h1>
<p class="text-primary-500 uppercase text-2xl mt-2">Pont-bascule connecté</p>
<div
v-if="!displayWeight"
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[86px]">
<UiLoadingDots />
</div>
<div v-else class="w-full">
<div
class="w-full flex flex-col items-center justify-center border border-black h-[90px] mt-12 mb-[25px] text-4xl text-primary-500">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesée' : 'peser' }}</UiButton>
<UiButton
v-if="displayWeight !== null && !showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="saveWeight"
>Valider la pesée</UiButton>
<UiButton
v-if="showGenerateReceipt"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="printReceipt"
>Générer le bon</UiButton>
</div>
</template>
<script setup lang="ts">
import { toRef } from 'vue'
import { useWeighingStep } from '~/composables/steps/useWeighingStep'
import type { WeightData } from '~/services/dto/weight-data'
const props = defineProps<{
mode: 'gross' | 'tare'
entityName: 'reception' | 'shipment'
apiResource: string
titleLabel: string
isFinal: boolean
entity: any
getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any>
loadEntity: (id: number) => Promise<any>
clearEntity: () => void
buildReceiptFilename: (entity: any) => string
}>()
const entityRef = toRef(props, 'entity')
const {
displayWeight,
title,
fetchWeight,
saveWeight,
saveWeightDraft,
showGenerateReceipt,
printReceipt
} = useWeighingStep({
mode: props.mode,
entity: entityRef,
entityName: props.entityName,
apiResource: props.apiResource,
titleLabel: props.titleLabel,
isFinal: props.isFinal,
getWeightFromScale: props.getWeightFromScale,
updateEntity: props.updateEntity,
loadEntity: props.loadEntity,
clearEntity: props.clearEntity,
buildReceiptFilename: props.buildReceiptFilename
})
defineExpose({ saveWeightDraft })
</script>

View File

@@ -0,0 +1,80 @@
import type { Ref } from 'vue'
import { useWeighing } from '~/composables/useWeighing'
import { usePdfPrinter } from '~/composables/usePdfPrinter'
import type { WeightData } from '~/services/dto/weight-data'
interface UseWeighingStepOptions {
mode: 'gross' | 'tare'
entity: Ref<any>
entityName: 'reception' | 'shipment'
apiResource: string
titleLabel: string
isFinal: boolean
getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any>
loadEntity: (id: number) => Promise<any>
clearEntity: () => void
buildReceiptFilename: (entity: any) => string
}
export const useWeighingStep = (options: UseWeighingStepOptions) => {
const router = useRouter()
const { printPdf } = usePdfPrinter()
const {
weightData,
currentWeightEntry,
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
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
})
const showGenerateReceipt = computed(
() => options.isFinal && displayWeight.value !== null
)
const printReceipt = async () => {
if (!import.meta.client || !options.entity.value) return
await saveWeight()
const entity = options.entity.value
const filename = options.buildReceiptFilename(entity)
await printPdf(`/${options.apiResource}/${entity.id}/receipt`, filename)
await new Promise((resolve) => setTimeout(resolve, 600))
const result = await options.updateEntity(entity.id, { isValid: true })
if (!result) return
options.clearEntity()
await router.push('/')
}
return {
weightData,
currentWeightEntry,
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
saveWeight,
saveWeightDraft,
showGenerateReceipt,
printReceipt
}
}

View File

@@ -0,0 +1,54 @@
import type { Ref } from 'vue'
import type { AddressData } from '~/services/dto/address-data'
interface AddressOwner {
id: number
addresses?: AddressData[]
}
export const useAddressSync = (
form: { addressId: string },
ownerId: Ref<string>,
owners: Ref<AddressOwner[]>
) => {
const ownerAddresses = computed<AddressData[]>(() => {
const id = Number(ownerId.value)
if (!Number.isFinite(id)) return []
return owners.value.find((owner) => owner.id === id)?.addresses ?? []
})
const addressOptions = computed(() =>
ownerAddresses.value.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
watch(
() => [ownerId.value, form.addressId, owners.value],
() => {
if (!ownerId.value) {
form.addressId = ''
return
}
if (!form.addressId && ownerAddresses.value.length === 1) {
form.addressId = String(ownerAddresses.value[0].id)
return
}
if (!form.addressId) return
const matches = ownerAddresses.value.some(
(address) => String(address.id) === form.addressId
)
if (!matches) {
if (ownerAddresses.value.length === 1) {
form.addressId = String(ownerAddresses.value[0].id)
} else {
form.addressId = ''
}
}
},
{ immediate: true }
)
return { ownerAddresses, addressOptions }
}

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,73 @@
import type { UserData } from '~/services/dto/user-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import { getUsers } from '~/services/auth'
import { getTruckList } from '~/services/truck'
import { getCarrierList } from '~/services/carrier'
import { useAuthStore } from '~/stores/auth'
export const useFormDataLoading = (form: { userId: string }) => {
const users = ref<UserData[]>([])
const trucks = ref<TruckData[]>([])
const carriers = ref<CarrierData[]>([])
const isLoadingUsers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const authStore = useAuthStore()
const loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
const loadCarriers = async () => {
isLoadingCarriers.value = true
try {
carriers.value = await getCarrierList()
} finally {
isLoadingCarriers.value = false
}
}
const setDefaultUser = () => {
if (form.userId) return
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
const loadCommonData = async () => {
await loadUsers()
await loadTrucks()
await loadCarriers()
await authStore.ensureSession()
setDefaultUser()
}
return {
users,
trucks,
carriers,
isLoadingUsers,
isLoadingTrucks,
isLoadingCarriers,
loadUsers,
loadTrucks,
loadCarriers,
setDefaultUser,
loadCommonData
}
}

View File

@@ -0,0 +1,153 @@
import type { Ref } from 'vue'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { DriverData } from '~/services/dto/driver-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
import { getDriverList } from '~/services/driver'
import { getVehicleList } from '~/services/vehicle'
import { SUPPLIER_CODE } from '~/utils/constants'
interface LiotForm {
carrierId: string
truckId: string
driverId: string
vehicleId: string
licensePlate: string
}
export const useLiotHandling = (
form: LiotForm,
carriers: Ref<CarrierData[]>,
isHydrating: Ref<boolean>
) => {
const drivers = ref<DriverData[]>([])
const vehicles = ref<VehicleData[]>([])
const isLoadingDrivers = ref(false)
const isLoadingVehicles = ref(false)
const allowAnyLicensePlate = ref(false)
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const filteredDrivers = computed<DriverData[]>(() => {
if (!form.carrierId) return []
return drivers.value.filter((driver) => String(driver.carrier?.id) === form.carrierId)
})
const filteredVehicles = computed<VehicleData[]>(() => {
if (!form.carrierId) return []
return vehicles.value.filter(
(vehicle) =>
String(vehicle.carrier?.id) === form.carrierId &&
(!form.truckId || String(vehicle.truck?.id) === form.truckId)
)
})
const loadDrivers = async () => {
isLoadingDrivers.value = true
try {
drivers.value = await getDriverList()
} finally {
isLoadingDrivers.value = false
}
}
const loadVehicles = async () => {
isLoadingVehicles.value = true
try {
vehicles.value = await getVehicleList()
} finally {
isLoadingVehicles.value = false
}
}
// Auto-select driver/vehicle when carrier changes
watch(
() => form.carrierId,
() => {
if (isHydrating.value) return
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
},
{ immediate: true }
)
// Validate/auto-select vehicle when truck/carrier changes
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) return
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) return
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{ immediate: true }
)
// Sync license plate from selected vehicle
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) return
if (isHydrating.value) return
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
// Auto-select vehicle from license plate
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) return
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
return {
drivers,
vehicles,
isLoadingDrivers,
isLoadingVehicles,
allowAnyLicensePlate,
isLiotCarrier,
filteredDrivers,
filteredVehicles,
loadDrivers,
loadVehicles
}
}

View File

@@ -1,57 +1,66 @@
import type {Ref} from 'vue' import { computed, ref } from 'vue'
import {computed, ref} from 'vue' import type { Ref } from 'vue'
import type {ReceptionData, ReceptionPayload, WeightEntryData} from '~/services/dto/reception-data' import type { WeightEntryData } from '~/services/dto/reception-data'
import type {WeightData} from '~/services/dto/weight-data' import type { WeightData } from '~/services/dto/weight-data'
import {getWeight} from '~/services/reception' import { createWeight, updateWeight } from '~/services/weight'
import {getWeightShipment} from '~/services/shipment'
import {createWeight, updateWeight} from '~/services/weight'
import type {UseWeighingShipmentOptions, UseWeighingOptions} from '~/services/weight'
import type {WeightShipmentEntryData} from "~/services/dto/shipment-data";
export type WeighingMode = 'gross' | 'tare' export type WeighingMode = 'gross' | 'tare'
export interface UseWeighingOptions {
mode: WeighingMode
entity: Ref<{ id: number; currentStep: number; isValid: boolean; weights?: WeightEntryData[] | null } | null>
entityName: 'reception' | 'shipment'
apiResource: string
titleLabel: string
isFinal?: boolean
getWeightFromScale: () => Promise<WeightData>
updateEntity: (id: number, payload: any) => Promise<any>
loadEntity?: (id: number) => Promise<any>
}
export const useWeighing = ({ export const useWeighing = ({
mode, mode,
reception, entity,
updateReception, entityName,
loadReception apiResource,
}: UseWeighingOptions) => { titleLabel,
isFinal = false,
getWeightFromScale,
updateEntity,
loadEntity
}: UseWeighingOptions) => {
const weightData = ref<WeightData | null>(null) const weightData = ref<WeightData | null>(null)
const isFetching = ref(false) const isFetching = ref(false)
const currentWeightEntry = computed<WeightEntryData | null>(() => { const currentWeightEntry = computed<WeightEntryData | null>(() => {
const weights = reception.value?.weights ?? [] const weights = entity.value?.weights ?? []
return weights.find((entry) => entry.type === mode) ?? null return weights.find((entry) => entry.type === mode) ?? null
}) })
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null) const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-') const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
const title = computed(() => (mode === 'gross' ? 'Pesée à plein' : 'Pesée à vide')) const title = computed(() => titleLabel)
const showLoadingBox = computed( const showLoadingBox = computed(() => isFetching.value)
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
)
const fetchWeight = async () => { const fetchWeight = async () => {
isFetching.value = true isFetching.value = true
weightData.value = await getWeight().finally(() => { weightData.value = await getWeightFromScale().finally(() => {
isFetching.value = false isFetching.value = false
}) })
} }
const saveWeight = async () => { const saveWeight = async () => {
if (!reception.value) { if (!entity.value) return
return
}
const existingEntry = currentWeightEntry.value const existingEntry = currentWeightEntry.value
const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null const baseDsd = weightData.value?.dsd ?? existingEntry?.dsd ?? null
const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null const baseWeight = weightData.value?.weight ?? existingEntry?.weight ?? null
const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null const baseWeighedAt = weightData.value?.weighedAt ?? existingEntry?.weighedAt ?? null
if (baseWeight === null) { if (baseWeight === null) return
return
} const relationPayload: Record<string, string> = {}
relationPayload[entityName] = `/api/${apiResource}/${entity.value.id}`
if (existingEntry?.id) { if (existingEntry?.id) {
await updateWeight(existingEntry.id, { await updateWeight(existingEntry.id, {
@@ -62,7 +71,7 @@ export const useWeighing = ({
}) })
} else { } else {
await createWeight({ await createWeight({
reception: `/api/receptions/${reception.value.id}`, ...relationPayload,
type: mode, type: mode,
dsd: baseDsd, dsd: baseDsd,
weight: baseWeight, weight: baseWeight,
@@ -70,16 +79,48 @@ export const useWeighing = ({
}) })
} }
const nextStep = mode === 'tare' const nextStep = isFinal
? reception.value.currentStep ? entity.value.currentStep
: reception.value.currentStep + 1 : entity.value.currentStep + 1
await updateReception(reception.value.id, { await updateEntity(entity.value.id, {
currentStep: nextStep, currentStep: nextStep,
isValid: reception.value.isValid isValid: entity.value.isValid
}) })
if (loadReception) { if (loadEntity) {
await loadReception(reception.value.id) await loadEntity(entity.value.id)
}
}
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
})
} }
} }
@@ -91,90 +132,34 @@ export const useWeighing = ({
title, title,
showLoadingBox, showLoadingBox,
fetchWeight, fetchWeight,
saveWeight saveWeight,
saveWeightDraft
} }
} }
// Backward-compatible aliases
export const useWeighingShipment = ({ export const useWeighingShipment = ({
modeShipment, modeShipment,
shipment, shipment,
updateShipment, updateShipment,
loadShipment loadShipment
}: UseWeighingShipmentOptions) => { }: {
const weightData = ref<WeightData | null>(null) modeShipment: WeighingMode
const isFetching = ref(false) shipment: Ref<any>
updateShipment: (id: number, payload: any) => Promise<any>
const currentWeightEntry = computed<WeightShipmentEntryData | null>(() => { loadShipment?: (id: number) => Promise<any>
const weights = shipment.value?.weights ?? [] }) => {
return weights.find((entry) => entry.type === modeShipment) ?? null return useWeighing({
mode: modeShipment,
entity: shipment,
entityName: 'shipment',
apiResource: 'shipments',
titleLabel: modeShipment === 'gross' ? 'Pesée à plein' : 'Pesée à vide',
getWeightFromScale: async () => {
const { getWeightShipment } = await import('~/services/shipment')
return getWeightShipment()
},
updateEntity: updateShipment,
loadEntity: loadShipment
}) })
const displayWeight = computed(() => weightData.value?.weight ?? currentWeightEntry.value?.weight ?? null)
const displayDsd = computed(() => weightData.value?.dsd ?? currentWeightEntry.value?.dsd ?? '-')
const title = computed(() => (modeShipment === 'gross' ? 'Pesée à vide' : 'Pesée à plein'))
const showLoadingBox = computed(
() => isFetching.value || (displayWeight.value === null && currentWeightEntry.value === null)
)
const fetchWeight = async () => {
isFetching.value = true
weightData.value = await getWeightShipment().finally(() => {
isFetching.value = false
})
}
const saveWeight = async () => {
if (!shipment.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
}
if (existingEntry?.id) {
await updateWeight(existingEntry.id, {
type: modeShipment,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
} else {
await createWeight({
shipment: `/api/shipments/${shipment.value.id}`,
type: modeShipment,
dsd: baseDsd,
weight: baseWeight,
weighedAt: baseWeighedAt
})
}
const nextStep = modeShipment === 'tare'
? shipment.value.currentStep
: shipment.value.currentStep + 1
await updateShipment(shipment.value.id, {
currentStep: nextStep,
isValid: shipment.value.isValid
})
if (loadShipment) {
await loadShipment(shipment.value.id)
}
}
return {
weightData,
currentWeightEntry,
displayWeight,
displayDsd,
title,
showLoadingBox,
fetchWeight,
saveWeight
}
} }

View File

@@ -0,0 +1,84 @@
import type { Ref } from 'vue'
import type { WorkflowConfig } from '~/types/workflow'
interface WorkflowStore {
current: any
isLoading: boolean
clearCurrent: () => void
[key: string]: any
}
export const useWorkflowSteps = (config: WorkflowConfig, store: WorkflowStore) => {
const route = useRoute()
const router = useRouter()
const stepLabels = config.steps.map(s => s.label)
const currentStep = computed(() => store.current?.currentStep ?? 0)
const entity = computed(() => store.current)
const loadMethod = `load${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`
const updateMethod = `update${config.entityName.charAt(0).toUpperCase() + config.entityName.slice(1)}`
const resolveId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const init = () => {
watch(
() => route.params.id,
async (param) => {
const id = resolveId(param)
if (id === null) {
store.clearCurrent()
return
}
await store[loadMethod](id)
},
{ immediate: true }
)
}
const saveAndHold = async () => {
if (!store.current) {
await router.push('/')
return
}
const datePayload: Record<string, any> = {}
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,
...datePayload
})
await router.push('/')
}
const handleStepSelect = async (step: number) => {
if (!store.current) return
if (step === store.current.currentStep) return
await store[updateMethod](store.current.id, { currentStep: step })
await store[loadMethod](store.current.id)
}
const advanceStep = async () => {
if (!store.current) return
const nextStep = store.current.currentStep + 1
await store[updateMethod](store.current.id, { currentStep: nextStep })
await store[loadMethod](store.current.id)
}
return {
stepLabels,
currentStep,
entity,
init,
saveAndHold,
handleStepSelect,
advanceStep
}
}

View File

@@ -0,0 +1,25 @@
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
export const receptionConfig: WorkflowConfig = {
entityName: 'reception',
apiResource: 'receptions',
steps: [
{ label: 'Réception' },
{ label: 'Pesée à plein', weighingMode: 'gross' },
{ label: 'Sélection réception' },
{ label: 'Pesée à vide', weighingMode: 'tare', isFinal: true }
],
weighingLabels: {
gross: 'Pesée à plein',
tare: 'Pesée à vide'
},
buildReceiptFilename: (entity: WorkflowEntity) => {
const rec = entity as any
return `${rec.identificationNumber ?? rec.id}_${rec.supplier?.name ?? 'fournisseur'}_${rec.licensePlate ?? 'immat'}.pdf`
},
routePrefix: '/reception',
toastPrefix: 'reception',
dateField: 'receptionDate'
}
export const RECEPTION_STEP_LABELS = receptionConfig.steps.map(s => s.label)

View File

@@ -0,0 +1,25 @@
import type { WorkflowConfig, WorkflowEntity } from '~/types/workflow'
export const shipmentConfig: WorkflowConfig = {
entityName: 'shipment',
apiResource: 'shipments',
steps: [
{ label: 'Expédition' },
{ label: 'Pesée à vide', weighingMode: 'tare' },
{ label: 'Chargement' },
{ label: 'Pesée à plein', weighingMode: 'gross', isFinal: true }
],
weighingLabels: {
gross: 'Pesée à plein',
tare: 'Pesée à vide'
},
buildReceiptFilename: (entity: WorkflowEntity) => {
const ship = entity as any
return `${ship.identificationNumber ?? ship.id}_${ship.customer?.label ?? 'client'}_${ship.licensePlate ?? 'immat'}.pdf`
},
routePrefix: '/shipment',
toastPrefix: 'shipment',
dateField: 'shipmentDate'
}
export const SHIPMENT_STEP_LABELS = shipmentConfig.steps.map(s => s.label)

View File

@@ -1,22 +0,0 @@
export enum StepLabel {
Reception = 'Réception',
GrossWeighing = 'Pesée à plein',
Selection = 'Sélection réception',
TareWeighing = 'Pesée à vide',
Shipment = 'Expédition',
ShipmentLoading = 'Chargement',
}
export const RECEPTION_STEP_LABELS = [
StepLabel.Reception,
StepLabel.GrossWeighing,
StepLabel.Selection,
StepLabel.TareWeighing
]
export const SHIPMENT_STEP_LABELS = [
StepLabel.Shipment,
StepLabel.TareWeighing,
StepLabel.ShipmentLoading,
StepLabel.GrossWeighing,
]

View File

@@ -12,6 +12,7 @@
"fetch": "Impossible de récupérer la réception.", "fetch": "Impossible de récupérer la réception.",
"create": "Impossible de créer la réception.", "create": "Impossible de créer la réception.",
"update": "Impossible de mettre à jour la réception.", "update": "Impossible de mettre à jour la réception.",
"delete": "Impossible de supprimer la réception.",
"weight": "Impossible de récupérer la pesée." "weight": "Impossible de récupérer la pesée."
}, },
"weight": { "weight": {
@@ -22,6 +23,7 @@
"fetch": "Impossible de récupérer l'éxpeditions.", "fetch": "Impossible de récupérer l'éxpeditions.",
"create": "Impossible de créer l'éxpeditions.", "create": "Impossible de créer l'éxpeditions.",
"update": "Impossible de mettre à jour l'éxpeditions.", "update": "Impossible de mettre à jour l'éxpeditions.",
"delete": "Impossible de supprimer l'expédition.",
"weigh": "Impossible de récupérer la pesée." "weigh": "Impossible de récupérer la pesée."
}, },
"shipmentBovine": { "shipmentBovine": {
@@ -87,6 +89,9 @@
"create": "Impossible de créer le type bovin.", "create": "Impossible de créer le type bovin.",
"update": "Impossible de mettre à jour le type bovin." "update": "Impossible de mettre à jour le type bovin."
}, },
"bovine": {
"create": "Impossible d'enregistrer le bovin."
},
"carrier": { "carrier": {
"list": "Impossible de récupérer la liste des transporteurs.", "list": "Impossible de récupérer la liste des transporteurs.",
"fetch": "Impossible de récupérer les données du transporteur", "fetch": "Impossible de récupérer les données du transporteur",
@@ -109,10 +114,14 @@
}, },
"success": { "success": {
"reception": { "reception": {
"update": "Réception mise à jour avec succès." "create": "Réception créée avec succès",
"update": "Réception mise à jour avec succès.",
"delete": "Réception supprimée avec succès."
}, },
"shipment": { "shipment": {
"update": "Éxpedition mise à jour avec succès." "create": "Éxpedition créée avec succès",
"update": "Éxpedition mise à jour avec succès.",
"delete": "Expédition supprimée avec succès."
}, },
"supplier": { "supplier": {
"create": "Fournisseur créé avec succès.", "create": "Fournisseur créé avec succès.",
@@ -140,6 +149,9 @@
"update": "Type bovin mis à jour avec succès.", "update": "Type bovin mis à jour avec succès.",
"create": "Type bovin créé avec succès." "create": "Type bovin créé avec succès."
}, },
"bovine": {
"create": "Bovin enregistré avec succès."
},
"weight": { "weight": {
"update": "Pesée mis à jour" "update": "Pesée mis à jour"
} }

View File

@@ -38,40 +38,6 @@
</a> </a>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/supplier/supplier-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/supplier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Fournisseurs
</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 <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/user/list" to="/admin/user/list"
@@ -89,6 +55,23 @@
</a> </a>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/supplier/supplier-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/supplier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Fournisseurs
</a>
</NuxtLink>
<NuxtLink <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/customer/customer-list" to="/admin/customer/customer-list"
@@ -106,6 +89,23 @@
</a> </a>
</NuxtLink> </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 <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/bovin/bovin-list" to="/admin/bovin/bovin-list"
@@ -122,6 +122,23 @@
Bovins Bovins
</a> </a>
</NuxtLink> </NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/scan"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/scan')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Scanner
</a>
</NuxtLink>
</nav> </nav>
<!-- Spacer mobile (pour centrer visuellement le header si besoin) --> <!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
@@ -134,7 +151,8 @@
class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80" class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80"
aria-haspopup="true" aria-haspopup="true"
> >
<span class="capitalize font-bold">{{ userDisplayName }}</span> <Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<span class="capitalize font-bold ml-4">{{ userDisplayName }}</span>
<span <span
class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180"> class="ml-[6px] inline-flex items-center font-bold transition-transform group-hover:rotate-180 group-focus-within:rotate-180">
<Icon name="mdi:chevron-down" size="20"/> <Icon name="mdi:chevron-down" size="20"/>
@@ -217,6 +235,9 @@
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu"> <NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
Bovins Bovins
</NuxtLink> </NuxtLink>
<NuxtLink to="/scan" @click="closeMenu">
Scanner
</NuxtLink>
</nav> </nav>
<button <button
@@ -230,7 +251,7 @@
</aside> </aside>
</transition> </transition>
</header> </header>
<main class="mx-auto w-full max-w-[1280px] mt-16"> <main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
<slot/> <slot/>
</main> </main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3"> <footer class="w-full mt-auto bg-primary-500 px-6 py-3">

View File

@@ -15,7 +15,8 @@ export default defineNuxtConfig({
css: ['~/assets/css/main.css', '~/assets/css/toast.css'], css: ['~/assets/css/main.css', '~/assets/css/toast.css'],
runtimeConfig: { runtimeConfig: {
public: { public: {
apiBase: process.env.NUXT_PUBLIC_API_BASE apiBase: process.env.NUXT_PUBLIC_API_BASE,
geoApiBase: ''
} }
}, },
toast: { toast: {

View File

@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between relative"> <div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]"> <div class="flex flex-row absolute -left-[60px]">
<Icon <Icon
@@ -15,14 +15,15 @@
</div> </div>
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]"> <div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" /> <UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" required />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" /> <UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<UiButton <UiButton
type="submit" type="submit"
:disabled="isLoading || isHydrating" :disabled="isLoading || isHydrating"
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -37,6 +38,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const isLoading = ref(false) const isLoading = ref(false)
const isHydrating = ref(false) const isHydrating = ref(false)
const submitted = ref(false)
const idBovin = computed(() => resolveId(route.params.id)) const idBovin = computed(() => resolveId(route.params.id))
const isEdit = computed(() => idBovin.value !== null) const isEdit = computed(() => idBovin.value !== null)

View File

@@ -1,6 +1,6 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between relative"> <div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]"> <div class="flex flex-row absolute -left-[60px]">
<Icon <Icon
@@ -20,18 +20,21 @@
label="Nom du transporteur" label="Nom du transporteur"
id="carrier-name" id="carrier-name"
v-model="form.name" v-model="form.name"
required
/> />
<UiTextInput <UiTextInput
label="Code transporteur" label="Code transporteur"
id="code-id" id="code-id"
v-model="form.code" v-model="form.code"
required
/> />
</div> </div>
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<UiButton <UiButton
type="submit" type="submit"
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -49,6 +52,7 @@ const route = useRoute()
const idCarrier = computed(() => resolveId(route.params.id)) const idCarrier = computed(() => resolveId(route.params.id))
const isLoading = ref(false) const isLoading = ref(false)
const isHydrating = ref(false) const isHydrating = ref(false)
const submitted = ref(false)
const resolveId = (param: unknown) => { const resolveId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param const idStr = Array.isArray(param) ? param[0] : param

View File

@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative"> <div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px] "> <div class="flex flex-row absolute -left-[60px] ">
<Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/> <Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
@@ -10,21 +10,36 @@
</div> </div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7"> <div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/> <UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/> <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]"/> <UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div> </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"> <div class="flex items-center justify-center">
<UiButton <UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading || !auth.isAdmin" :disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
> >
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" /> <Icon :name="customerId ? '' : 'mdi:plus'" size="28" />
{{ customerId ? "Valider" : "Ajouter" }} {{ customerId ? "Valider" : "Ajouter" }}
</UiButton> </UiButton>
</div> </div>
<template v-if="customerId">
<div class="flex items-center justify-between mb-7"> <div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2> <h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2>
</div> </div>
@@ -32,7 +47,6 @@
<table class="w-full border-collapse text-primary-700"> <table class="w-full border-collapse text-primary-700">
<thead> <thead>
<tr class="text-left border bg-slate-100 border-gray-200"> <tr class="text-left border bg-slate-100 border-gray-200">
<th class="py-3 px-4 text-sm uppercase">Libellé</th>
<th class="py-3 px-4 text-sm uppercase">Rue</th> <th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 px-4 text-sm uppercase">Complément</th> <th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 px-4 text-sm uppercase">Code postal</th> <th class="py-3 px-4 text-sm uppercase">Code postal</th>
@@ -43,7 +57,7 @@
<tbody> <tbody>
<template v-if="form.addresses.length === 0"> <template v-if="form.addresses.length === 0">
<tr> <tr>
<td colspan="6" class="py-4 text-slate-400"> <td colspan="5" class="py-4 text-slate-400">
Aucune adresse. Aucune adresse.
</td> </td>
</tr> </tr>
@@ -56,7 +70,6 @@
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'" :class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)" @click="goToEditAddress(address.id ?? null)"
> >
<td class="py-3 px-4">{{ address.label || "—" }}</td>
<td class="py-3 px-4">{{ address.street || "—" }}</td> <td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 px-4">{{ address.street2 || "—" }}</td> <td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td> <td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
@@ -78,6 +91,7 @@
Ajouter Ajouter
</UiButton> </UiButton>
</div> </div>
</template>
</form> </form>
</template> </template>
@@ -85,6 +99,8 @@
import {computed, reactive, ref, watch} from "vue" import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer" import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data" 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" import {useAuthStore} from "~/stores/auth"
const route = useRoute() const route = useRoute()
@@ -99,6 +115,7 @@ const resolveId = (param: unknown) => {
} }
const customerId = computed(() => resolveId(route.params.id)) const customerId = computed(() => resolveId(route.params.id))
const isLoading = ref(false) const isLoading = ref(false)
const submitted = ref(false)
const form = reactive<CustomerFormData>({ const form = reactive<CustomerFormData>({
name: "", name: "",
phone: "", phone: "",
@@ -106,6 +123,30 @@ const form = reactive<CustomerFormData>({
addresses: [], 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 = () => { const goToAddAddress = () => {
if (customerId.value === null || !auth.isAdmin) return if (customerId.value === null || !auth.isAdmin) return
router.push({ router.push({
@@ -143,7 +184,6 @@ const hydrateFromCustomer = (customer: CustomerData | null) => {
form.addresses = customer.addresses.map((address) => ({ form.addresses = customer.addresses.map((address) => ({
id: address.id ?? null, id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "", street: address.street ?? "",
street2: address.street2 ?? null, street2: address.street2 ?? null,
postalCode: address.postalCode ?? "", postalCode: address.postalCode ?? "",
@@ -188,7 +228,14 @@ async function validate() {
await updateCustomer(customerId.value, customerPayload) await updateCustomer(customerId.value, customerPayload)
targetId = customerId.value targetId = customerId.value
} else { } else {
const created = await createCustomer(customerPayload) 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)
targetId = created.id targetId = created.id
} }

View File

@@ -6,72 +6,28 @@
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11"> <div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
<div class="max-h-96 overflow-y-auto"> <div class="max-h-96 overflow-y-auto">
<div <div
class="sticky text-primary-700 top-0 z-10 grid grid-cols-8 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide" 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>Nom</div>
<div>Téléphone</div> <div>Téléphone</div>
<div>Email</div> <div>Mail</div>
<div>Rue</div> <div>Créé par</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</div>
</div> </div>
<div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400"> <div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400">
Aucun client. Aucun client.
</div> </div>
<div v-for="customer in customerList" :key="customer.id"> <div
<div v-for="customer in customerList"
v-if="!customer.addresses || customer.addresses.length === 0" :key="customer.id"
class="grid text-primary-700 grid-cols-8 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer" 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)" @click="goToCustomer(customer.id)"
> >
<div class="truncate">{{ customer.name || "—" }}</div> <div class="truncate">{{ customer.name || "—" }}</div>
<div class="truncate">{{ customer.phone || "—" }}</div> <div class="truncate">{{ customer.phone || "—" }}</div>
<div class="truncate">{{ customer.email || "—" }}</div> <div class="truncate">{{ customer.email || "—" }}</div>
<div class="col-span-1">Pas d'adresse</div> <div class="truncate">{{ customer.createdBy?.username || "—" }}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
</div>
<template v-else-if="customer.addresses.length > 0">
<div
v-for="(address, idx) in customer.addresses"
:key="address.id ?? `${customer.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-8 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
@click="goToCustomer(customer.id)"
>
<div class="truncate">
{{ idx === 0 ? (customer.name || "—") : "↳" }}
</div>
<div class="truncate">{{ idx === 0 ? (customer.phone || "—") : "" }}</div>
<div class="truncate">{{ idx === 0 ? (customer.email || "—") : "" }}</div>
<div class="truncate">{{ address.street || "—" }}</div>
<div class="truncate">{{ address.street2 || "—" }}</div>
<div>{{ address.postalCode || "—" }}</div>
<div class="uppercase truncate">{{ address.city || "—" }}</div>
<div class="uppercase truncate">{{ address.countryCode || "—" }}</div>
</div>
</template>
<template v-else>
<div
class="grid grid-cols-8 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="col-span-5 text-slate-400">
Adresses non chargées
</div>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative"> <div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px] "> <div class="flex flex-row absolute -left-[60px] ">
@@ -11,21 +11,36 @@
</div> </div>
<div class="flex flex-cols-3 justify-between mb-11 pt-7"> <div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/> <UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<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]"/> <UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div> </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"> <div class="flex items-center justify-center">
<UiButton <UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading || !auth.isAdmin" :disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
> >
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" /> <Icon :name="supplierId ? '' : 'mdi:plus'" size="28" />
{{ supplierId ? "Valider" : "Ajouter" }} {{ supplierId ? "Valider" : "Ajouter" }}
</UiButton> </UiButton>
</div> </div>
<template v-if="supplierId">
<div class="flex items-center justify-between mb-7"> <div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2> <h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2>
</div> </div>
@@ -33,7 +48,6 @@
<table class="w-full border-collapse"> <table class="w-full border-collapse">
<thead> <thead>
<tr class="text-left border bg-slate-100 border-gray-200"> <tr class="text-left border bg-slate-100 border-gray-200">
<th class="py-3 px-4 text-sm uppercase">Libellé</th>
<th class="py-3 px-4 text-sm uppercase">Rue</th> <th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 px-4 text-sm uppercase">Complément</th> <th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 px-4 text-sm uppercase">Code postal</th> <th class="py-3 px-4 text-sm uppercase">Code postal</th>
@@ -44,7 +58,7 @@
<tbody> <tbody>
<template v-if="form.addresses.length === 0"> <template v-if="form.addresses.length === 0">
<tr> <tr>
<td colspan="6" class="py-4 text-slate-400"> <td colspan="5" class="py-4 text-slate-400">
Aucune adresse. Aucune adresse.
</td> </td>
</tr> </tr>
@@ -57,7 +71,6 @@
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'" :class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)" @click="goToEditAddress(address.id ?? null)"
> >
<td class="py-3 px-4">{{ address.label || "—" }}</td>
<td class="py-3 px-4">{{ address.street || "—" }}</td> <td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 px-4">{{ address.street2 || "—" }}</td> <td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 px-4">{{ address.postalCode || "—" }}</td> <td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
@@ -79,6 +92,7 @@
Ajouter Ajouter
</UiButton> </UiButton>
</div> </div>
</template>
</form> </form>
</template> </template>
@@ -86,6 +100,8 @@
import {computed, reactive, ref, watch} from "vue" import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier" import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data" 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" import {useAuthStore} from "~/stores/auth"
const route = useRoute() const route = useRoute()
@@ -100,6 +116,7 @@ const resolveId = (param: unknown) => {
} }
const supplierId = computed(() => resolveId(route.params.id)) const supplierId = computed(() => resolveId(route.params.id))
const isLoading = ref(false) const isLoading = ref(false)
const submitted = ref(false)
const form = reactive<SupplierFormData>({ const form = reactive<SupplierFormData>({
name: "", name: "",
email: "", email: "",
@@ -107,6 +124,30 @@ const form = reactive<SupplierFormData>({
addresses: [], 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 = () => { const goToAddAddress = () => {
if (supplierId.value === null || !auth.isAdmin) return if (supplierId.value === null || !auth.isAdmin) return
router.push({ router.push({
@@ -146,7 +187,6 @@ const hydrateFromSupplier = (supplier: SupplierData | null) => {
form.addresses = supplier.addresses.map((address) => ({ form.addresses = supplier.addresses.map((address) => ({
id: address.id ?? null, id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "", street: address.street ?? "",
street2: address.street2 ?? null, street2: address.street2 ?? null,
postalCode: address.postalCode ?? "", postalCode: address.postalCode ?? "",
@@ -191,7 +231,14 @@ async function validate() {
await updateSupplier(supplierId.value, supplierPayload) await updateSupplier(supplierId.value, supplierPayload)
targetId = supplierId.value targetId = supplierId.value
} else { } else {
const created = await createSupplier(supplierPayload) 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)
targetId = created.id targetId = created.id
} }

View File

@@ -6,68 +6,28 @@
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11"> <div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
<div class="max-h-96 overflow-y-auto"> <div class="max-h-96 overflow-y-auto">
<div <div
class="sticky text-primary-700 top-0 z-10 grid grid-cols-7 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide" 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>Nom</div>
<div>Téléphone</div>
<div>Mail</div> <div>Mail</div>
<div>Rue</div> <div>Créé par</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</div>
</div> </div>
<div v-if="supplierList.length === 0" class="px-4 py-6 text-slate-400"> <div v-if="supplierList.length === 0" class="px-4 py-6 text-slate-400">
Aucun fournisseur. Aucun fournisseur.
</div> </div>
<div v-for="supplier in supplierList" :key="supplier.id"> <div
<div v-for="supplier in supplierList"
v-if="!supplier.addresses || supplier.addresses.length === 0" :key="supplier.id"
class="grid text-primary-700 grid-cols-7 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer" 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)" @click="goToSupplier(supplier.id)"
> >
<div class="truncate">{{ supplier.name }}</div> <div class="truncate">{{ supplier.name || "—" }}</div>
<div class="truncate">{{ supplier.email }}</div> <div class="truncate">{{ supplier.phone || "—" }}</div>
<div class="col-span-1">Pas d'adresse</div> <div class="truncate">{{ supplier.email || "—" }}</div>
<div class="uppercase truncate">{{"—"}}</div> <div class="truncate">{{ supplier.createdBy?.username || "—" }}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
<div class="uppercase truncate">{{"—"}}</div>
</div>
<template v-else-if="supplier.addresses.length > 0">
<div
v-for="(address, idx) in supplier.addresses"
:key="address.id ?? `${supplier.id}-${idx}-${address.street}-${address.postalCode}`"
class="grid grid-cols-7 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
:class="idx > 0 ? 'pl-4 border-l-4 border-l-slate-200 bg-slate-50' : ''"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">
{{ idx === 0 ? supplier.name : "↳" }}
</div>
<div class="truncate">{{ idx === 0 ? supplier.email : "" }}</div>
<div class="truncate">{{ address.street || "—" }}</div>
<div class="truncate">{{ address.street2 || "—" }}</div>
<div>{{ address.postalCode || "—" }}</div>
<div class="uppercase truncate">{{ address.city || "—" }}</div>
<div class="uppercase truncate">{{ address.countryCode || "—" }}</div>
</div>
</template>
<template v-else>
<div
class="grid grid-cols-7 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.email }}</div>
<div class="col-span-5 text-slate-400">
Adresses non chargées
</div>
</div>
</template>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative"> <div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px]"> <div class="flex flex-row absolute -left-[60px]">
<Icon <Icon
@@ -21,6 +21,7 @@
label="Nom de l'utilisateur" label="Nom de l'utilisateur"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
wrapper-class="w-[280px]" wrapper-class="w-[280px]"
required
/> />
<UiSelect <UiSelect
@@ -30,6 +31,7 @@
:options="ROLE" :options="ROLE"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
wrapper-class="w-[280px]" wrapper-class="w-[280px]"
required
/> />
<UiTextInput <UiTextInput
@@ -39,14 +41,30 @@
type="password" type="password"
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
wrapper-class="w-[280px]" wrapper-class="w-[280px]"
:required="!userId"
/> />
</div> </div>
<div class="flex items-center mb-11">
<label class="flex items-center gap-2 cursor-pointer">
<input
id="user-locked"
v-model="form.isLocked"
type="checkbox"
:disabled="!auth.isAdmin"
class="w-5 h-5 accent-primary-500"
/>
<span class="text-sm text-primary-700">Verrouiller le compte</span>
</label>
<p class="ml-4 text-xs text-slate-400">Un compte verrouillé ne peut plus se connecter.</p>
</div>
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<UiButton <UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end" class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading || isHydrating || !auth.isAdmin" :disabled="isLoading || isHydrating || !auth.isAdmin"
@click="submitted = true"
> >
<Icon :name="userId ? '' : 'mdi:plus'" size="28" /> <Icon :name="userId ? '' : 'mdi:plus'" size="28" />
{{ userId ? 'Valider' : 'Ajouter' }} {{ userId ? 'Valider' : 'Ajouter' }}
@@ -68,6 +86,7 @@ const auth = useAuthStore()
const userId = computed(() => resolveUserId(route.params.id)) const userId = computed(() => resolveUserId(route.params.id))
const isLoading = ref(false) const isLoading = ref(false)
const isHydrating = ref(false) const isHydrating = ref(false)
const submitted = ref(false)
const resolveUserId = (param: unknown) => { const resolveUserId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param const idStr = Array.isArray(param) ? param[0] : param
@@ -81,7 +100,8 @@ const resolveUserId = (param: unknown) => {
const form = reactive<UserFormData>({ const form = reactive<UserFormData>({
username: '', username: '',
password: '', password: '',
role: '' role: '',
isLocked: false
}) })
const hydrateFromUser = (user: UserData | null) => { const hydrateFromUser = (user: UserData | null) => {
@@ -94,6 +114,7 @@ const hydrateFromUser = (user: UserData | null) => {
const hasAdmin = roles.includes('ROLE_ADMIN') const hasAdmin = roles.includes('ROLE_ADMIN')
form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER' form.role = hasAdmin ? 'ROLE_ADMIN' : 'ROLE_USER'
form.password = '' form.password = ''
form.isLocked = user.isLocked ?? false
isHydrating.value = false isHydrating.value = false
} }
@@ -124,6 +145,7 @@ async function validate() {
const basePayload: UserPayload = { const basePayload: UserPayload = {
username: normalizedUsername, username: normalizedUsername,
roles: normalizedRole ? [normalizedRole] : undefined, roles: normalizedRole ? [normalizedRole] : undefined,
isLocked: form.isLocked,
} }
if (normalizedPassword) { if (normalizedPassword) {
basePayload.password = normalizedPassword basePayload.password = normalizedPassword

View File

@@ -4,9 +4,10 @@
</div> </div>
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11"> <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>Utilisateur</div>
<div>Role</div> <div>Role</div>
<div>Statut</div>
</div> </div>
<div v-if="userList.length === 0" class="px-4 py-6 text-slate-400"> <div v-if="userList.length === 0" class="px-4 py-6 text-slate-400">
Aucun utilisateur. Aucun utilisateur.
@@ -15,7 +16,7 @@
<div <div
v-for="user in userList" v-for="user in userList"
:key="user.id" :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" role="button"
tabindex="0" tabindex="0"
@click="goToUser(user.id)" @click="goToUser(user.id)"
@@ -23,6 +24,16 @@
> >
<div>{{ user.username }}</div> <div>{{ user.username }}</div>
<div>{{ getRoleLabels(user.roles) }}</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> </div>
</template> </template>
</div> </div>

View File

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

View File

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

View File

@@ -1,21 +1,118 @@
<template> <template>
<div class="flex justify-center items-center"> <div class="px-[86px]">
<UiButton <div class="flex items-center justify-between relative">
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]" <div class="flex flex-row absolute -left-[60px]">
:disabled="!hasCaseId" <Icon
@click="printCaseReport" @click="router.push('/infrastructure/building')"
> name="gg:arrow-left-o"
Imprimer size="44"
</UiButton> class="cursor-pointer text-primary-500"
/>
</div>
<div class="flex items-center gap-4">
<h1 class="font-bold text-4xl text-primary-500 uppercase">
{{ title }}
</h1>
<div
v-if="hasCaseId"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
title="Imprimer"
@click="printCaseReport"
>
<Icon name="mdi:printer-outline" size="32" class="text-white" />
</div>
</div>
<NuxtLink
v-if="hasCaseId"
: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 border border-slate-200 mb-16">
<div
class="grid grid-cols-3 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
>
<div>Numéro national</div>
<div>Poids à l'arrivée (kg)</div>
<div>Date d'arrivée</div>
</div>
<template v-if="bovines.length > 0">
<div
v-for="bovine in bovines"
:key="bovine.id"
class="grid grid-cols-3 gap-4 px-4 py-3 text-sm border-t border-slate-200"
:class="auth.isAdmin ? 'cursor-pointer hover:bg-slate-50' : ''"
:role="auth.isAdmin ? 'button' : undefined"
:tabindex="auth.isAdmin ? 0 : undefined"
@click="goToBovine(bovine.id)"
@keydown.enter="goToBovine(bovine.id)"
>
<div>{{ bovine.nationalNumber }}</div>
<div>{{ bovine.receivedWeight ?? '—' }}</div>
<div>{{ formatDate(bovine.arrivalDate) }}</div>
</div>
</template>
<div
v-else
class="px-4 py-3 text-sm border-t border-slate-200 text-slate-500"
>
Aucun bovin dans cette case.
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import { useAuthStore } from '~/stores/auth'
const route = useRoute() const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter() const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id)) const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0) const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
const buildingCase = ref<BuildingCaseData | null>(null)
const bovines = computed(() => buildingCase.value?.bovines ?? [])
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 () => { const printCaseReport = async () => {
if (!hasCaseId.value) { if (!hasCaseId.value) {
return return
@@ -24,4 +121,14 @@ const printCaseReport = async () => {
const filename = `tableau_poids_case_${caseId.value}.pdf` const filename = `tableau_poids_case_${caseId.value}.pdf`
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename) await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
} }
const goToBovine = (id: number) => {
if (!auth.isAdmin) return
router.push({
path: '/infrastructure/bovine',
query: { id: String(id), caseId: String(caseId.value) }
})
}
watch(caseId, loadCase, { immediate: true })
</script> </script>

View File

@@ -16,6 +16,7 @@
<select <select
id="user-select" id="user-select"
v-model="selectedUsername" v-model="selectedUsername"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-200"
:disabled="isLoadingUsers" :disabled="isLoadingUsers"
> >

View File

@@ -2,7 +2,7 @@
<div class="flex justify-between h-[52px] mb-[80px]"> <div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16"> <div class="flex flex-1 mr-16">
<UiStepper <UiStepper
:labels="RECEPTION_STEP_LABELS" :labels="stepLabels"
:current-step="storeReception?.currentStep ?? 0" :current-step="storeReception?.currentStep ?? 0"
@select="handleStepSelect" @select="handleStepSelect"
/> />
@@ -14,77 +14,89 @@
>Mettre en attente >Mettre en attente
</UiButton> </UiButton>
</div> </div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/> <ReceptionForm v-if="!storeReception || storeReception.currentStep === 0" ref="receptionFormRef"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/> <WorkflowWeight
v-if="storeReception?.currentStep === 1"
ref="grossWeightRef"
mode="gross"
entity-name="reception"
api-resource="receptions"
:title-label="receptionConfig.weighingLabels.gross"
:is-final="false"
:entity="storeReception"
:get-weight-from-scale="getWeight"
:update-entity="receptionStore.updateReception"
:load-entity="receptionStore.loadReception"
:clear-entity="receptionStore.clearCurrent"
:build-receipt-filename="receptionConfig.buildReceiptFilename"
/>
<ReceptionProductReceived <ReceptionProductReceived
v-if="storeReception?.currentStep === 2 && v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/> receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
<ReceptionBovineReceived <ReceptionBovineReceived
v-if="storeReception?.currentStep === 2 && v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/> receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/> <WorkflowWeight
v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3"
ref="tareWeightRef"
mode="tare"
entity-name="reception"
api-resource="receptions"
:title-label="receptionConfig.weighingLabels.tare"
:is-final="true"
:entity="storeReception"
:get-weight-from-scale="getWeight"
:update-entity="receptionStore.updateReception"
:load-entity="receptionStore.loadReception"
:clear-entity="receptionStore.clearCurrent"
:build-receipt-filename="receptionConfig.buildReceiptFilename"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {useReceptionStore} from '~/stores/reception' import { useReceptionStore } from '~/stores/reception'
import {storeToRefs} from 'pinia' import { storeToRefs } from 'pinia'
import {RECEPTION_STEP_LABELS} from '~/constants/steps' import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
import {RECEPTION_TYPE_CODES} from "~/utils/constants"; import { receptionConfig } from '~/config/reception.config'
import { getWeight } from '~/services/reception'
const route = useRoute() import { RECEPTION_TYPE_CODES } from '~/utils/constants'
const router = useRouter()
const receptionStore = useReceptionStore() const receptionStore = useReceptionStore()
const {current: storeReception} = storeToRefs(receptionStore) 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 resolveReceptionId = (param: unknown) => { const { stepLabels, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore)
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
watch( const router = useRouter()
() => route.params.id,
async (param) => {
const id = resolveReceptionId(param)
if (id === null) {
receptionStore.clearCurrent()
return
}
await receptionStore.loadReception(id)
},
{immediate: true}
)
const saveAndHold = async () => { const saveAndHold = async () => {
if (!receptionStore.current) { if (receptionFormRef.value) {
await router.push('/') if (!receptionFormRef.value.validateFields()) return
return await receptionFormRef.value.saveDraft()
} else {
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
} }
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: receptionStore.current.currentStep,
licensePlate: receptionStore.current.licensePlate,
receptionDate: receptionStore.current.receptionDate
})
await router.push('/') await router.push('/')
} }
const handleStepSelect = async (step: number) => { // Init route watcher
if (!receptionStore.current) { const route = useRoute()
return watch(
} () => route.params.id,
async (param) => {
if (step === receptionStore.current.currentStep) { const idStr = Array.isArray(param) ? param[0] : param
return if (!idStr) {
} receptionStore.clearCurrent()
return
await receptionStore.updateReception(receptionStore.current.id, { }
currentStep: step const id = Number(idStr)
}) if (Number.isFinite(id)) {
await receptionStore.loadReception(receptionStore.current.id) await receptionStore.loadReception(id)
} }
},
{ immediate: true }
)
</script> </script>

View File

@@ -8,7 +8,7 @@
<div class="mt-6 border border-slate-200 mb-16 "> <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 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>Numéro</div>
<div>Date</div> <div>Date et heure</div>
<div>Fournisseur</div> <div>Fournisseur</div>
<div>Adresse</div> <div>Adresse</div>
<div>Type réception</div> <div>Type réception</div>
@@ -23,7 +23,7 @@
@click="goToReception(reception.id)" @click="goToReception(reception.id)"
> >
<div>{{ reception.identificationNumber}}</div> <div>{{ reception.identificationNumber}}</div>
<div>{{ reception.receptionDate}}</div> <div>{{ formatDate(reception.receptionDate) }}</div>
<div>{{ reception.supplier?.name }}</div> <div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div> <div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div> <div>{{ reception.receptionType?.label }}</div>
@@ -41,6 +41,19 @@ import type {ShipmentData} from "~/services/dto/shipment-data";
const receptionList = ref<ReceptionData[]>() const receptionList = ref<ReceptionData[]>()
const router = useRouter() const router = useRouter()
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 formatWeighing = (reception: ReceptionData) => { const formatWeighing = (reception: ReceptionData) => {
const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight const gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight

View File

@@ -1,13 +1,15 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-[60px]"> <div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-[60px]">
<div class="flex items-center justify-between gap-10 relative"> <div class="flex items-center justify-between gap-10 relative">
<div class="flex flex-row absolute -left-[60px] justify-between"> <div class="flex flex-row absolute -left-[60px] justify-between">
<Icon @click="router.push('/reception/finish-reception')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> <Icon @click="router.push('/reception/finish-reception')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
</div> </div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Réception {{ form.identificationNumber }}</h1> <h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Réception {{ form.identificationNumber }}</h1>
<Icon @click="printReceipt" name="mdi:printer-outline" size="44" class="cursor-pointer text-primary-500"/> <div class="bg-primary-500 p-1 rounded-md flex items-center" title="Imprimer" @click="printReceipt">
<Icon name="mdi:printer-outline" size="32" class="cursor-pointer text-white"/>
</div>
</div> </div>
<!-- Nom de l'utilisateur --> <!-- Nom de l'utilisateur -->
<UiSelect <UiSelect
@@ -21,6 +23,7 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<!-- Date de réception --> <!-- Date de réception -->
<UiDateInput <UiDateInput
@@ -29,6 +32,7 @@
v-model="form.receptionDate" v-model="form.receptionDate"
label="Date de réception" label="Date de réception"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<!-- type de reception --> <!-- type de reception -->
<UiSelect <UiSelect
@@ -42,6 +46,7 @@
}))" }))"
:loading="isLoadingSuppliers" :loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-4" wrapper-class="col-start-1 row-start-4"
required
/> />
<!-- Fournisseur --> <!-- Fournisseur -->
<UiSelect <UiSelect
@@ -55,6 +60,7 @@
}))" }))"
:loading="isLoadingSuppliers" :loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-5" wrapper-class="col-start-1 row-start-5"
required
/> />
<!-- Adresse fournisseur --> <!-- Adresse fournisseur -->
<UiSelect <UiSelect
@@ -67,6 +73,7 @@
}))" }))"
:disabled="(isLoadingSuppliers || supplierAddresses.length === 0) && !auth.isAdmin" :disabled="(isLoadingSuppliers || supplierAddresses.length === 0) && !auth.isAdmin"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<!-- Camion --> <!-- Camion -->
<UiSelect <UiSelect
@@ -80,6 +87,7 @@
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-2"
required
/> />
<!-- Transporteur --> <!-- Transporteur -->
<UiSelect <UiSelect
@@ -94,6 +102,7 @@
:loading="isLoadingCarriers" :loading="isLoadingCarriers"
select-class="h-[34px]" select-class="h-[34px]"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<!-- Chauffeur (LIOT) --> <!-- Chauffeur (LIOT) -->
<UiSelect <UiSelect
@@ -108,6 +117,7 @@
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
wrapper-class="col-start-2 row-start-5" wrapper-class="col-start-2 row-start-5"
v-if="isLiotCarrier" v-if="isLiotCarrier"
required
/> />
<!-- Plaque d'immatriculation --> <!-- Plaque d'immatriculation -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
@@ -115,6 +125,7 @@
:disabled="!auth.isAdmin" :disabled="!auth.isAdmin"
v-model="form.licensePlate" v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate" v-model:allowAny="allowAnyLicensePlate"
required
/> />
</div> </div>
<!-- Immatriculation (LIOT) --> <!-- Immatriculation (LIOT) -->
@@ -130,27 +141,37 @@
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="(isLoadingVehicles || filteredVehicles.length === 0) && !auth.isAdmin" :disabled="(isLoadingVehicles || filteredVehicles.length === 0) && !auth.isAdmin"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-4"
required
/> />
</div> </div>
<div v-if="formIsLoading"> <div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60"> <div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1 <h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer " class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'" :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'" @click="activeTab = 'weights'"
> >
pesée à plein pesée à plein
</h1> </h1>
<h1 <h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer " 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'" :class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'" @click="activeTab = 'weightsEmpty'"
> >
pesée à vide pesée à vide
</h1> </h1>
<h1 <h1
class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer " class="font-bold text-3xl uppercase px-12 col-start-2 row-start-1 cursor-pointer"
:class="activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'" :class="[
activeTab === 'merchandise' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasMerchandiseTabError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'merchandise'" @click="activeTab = 'merchandise'"
> >
{{ isMerchandise ? "Marchandise" : "Bovins" }} {{ isMerchandise ? "Marchandise" : "Bovins" }}
@@ -176,6 +197,9 @@
v-model="merchandiseForm" v-model="merchandiseForm"
:isAdmin="auth.isAdmin" :isAdmin="auth.isAdmin"
/> />
<p v-if="activeTab === 'merchandise' && isMerchandise" class="text-red-500 text-sm mt-2" :class="showMerchandiseError ? '' : 'invisible'">
{{ merchandiseErrorMessage || '&nbsp;' }}
</p>
<update-bovin <update-bovin
v-if="activeTab === 'merchandise' && !isMerchandise" v-if="activeTab === 'merchandise' && !isMerchandise"
@@ -183,12 +207,16 @@
v-model:otherQuantity="bovineOtherQuantity" v-model:otherQuantity="bovineOtherQuantity"
:isAdmin="auth.isAdmin" :isAdmin="auth.isAdmin"
/> />
<p v-if="activeTab === 'merchandise' && !isMerchandise" class="text-red-500 text-sm mt-2" :class="showMerchandiseError ? '' : 'invisible'">
{{ merchandiseErrorMessage || '&nbsp;' }}
</p>
</div> </div>
<div class="flex justify-center"> <div class="flex justify-center">
<UiButton <UiButton
v-if="auth.isAdmin" v-if="auth.isAdmin"
type="submit" type="submit"
class="inline-flex mb-16 items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2 justify-self-end" class="inline-flex mb-16 items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2 justify-self-end"
@click="submitted = true"
> >
Valider Valider
</UiButton> </UiButton>
@@ -239,7 +267,9 @@ import { getVehicleList } from '~/services/vehicle'
import { createWeight, updateWeight } from '~/services/weight' import { createWeight, updateWeight } from '~/services/weight'
import { useAuthStore } from '~/stores/auth' import { useAuthStore } from '~/stores/auth'
import { useReceptionStore } from '~/stores/reception' import { useReceptionStore } from '~/stores/reception'
import { RECEPTION_TYPE_CODES, SUPPLIER_CODE } from '~/utils/constants' import { MERCHANDISE_TYPE_CODES, RECEPTION_TYPE_CODES, SUPPLIER_CODE } from '~/utils/constants'
import { getMerchandiseTypeList } from '~/services/merchandise-type'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -260,6 +290,17 @@ const merchandiseForm = ref<MerchandiseEntryData>({
selectedPelletBuildingIds: {} selectedPelletBuildingIds: {}
}) })
const allowAnyLicensePlate = ref(false) const allowAnyLicensePlate = ref(false)
const submitted = ref(false)
const showMerchandiseError = ref(false)
const merchandiseErrorMessage = ref('')
const hasGrossWeightError = computed(() =>
submitted.value && (grossWeight.value.weight === null || grossWeight.value.weighedAt === null || grossWeight.value.dsd === null)
)
const hasTareWeightError = computed(() =>
submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null)
)
const hasMerchandiseTabError = computed(() => submitted.value && showMerchandiseError.value)
const isLoading = ref(false) const isLoading = ref(false)
const users = ref<UserData[]>([]) const users = ref<UserData[]>([])
const isLoadingUsers = ref(false) const isLoadingUsers = ref(false)
@@ -277,6 +318,7 @@ const vehicles = ref<VehicleData[]>([])
const isLoadingVehicles = ref(false) const isLoadingVehicles = ref(false)
const formIsLoading = ref(false) const formIsLoading = ref(false)
const isMerchandise = ref(false) const isMerchandise = ref(false)
const merchandiseTypesList = ref<MerchandiseTypeData[]>([])
const isHydrating = ref(false) const isHydrating = ref(false)
const vehicleSyncLock = ref(false) const vehicleSyncLock = ref(false)
@@ -436,7 +478,7 @@ function hydrateFromReception(reception: ReceptionData | null) {
isHydrating.value = true isHydrating.value = true
form.identificationNumber = reception?.identificationNumber ?? '' form.identificationNumber = reception?.identificationNumber ?? ''
form.licensePlate = reception?.licensePlate ?? '' form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate ?? new Date().toISOString().slice(0, 10) form.receptionDate = reception?.receptionDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
form.userId = reception?.user?.id form.userId = reception?.user?.id
? String(reception.user.id) ? String(reception.user.id)
: form.userId : form.userId
@@ -776,6 +818,53 @@ async function validate() {
} }
if (idReception) { if (idReception) {
const hasInvalidWeights =
grossWeight.value.weight === null || grossWeight.value.weighedAt === null || grossWeight.value.dsd === null ||
tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null
if (hasInvalidWeights) {
return
}
showMerchandiseError.value = false
merchandiseErrorMessage.value = ''
if (!isMerchandise.value && getTotalBovines() === 0) {
showMerchandiseError.value = true
merchandiseErrorMessage.value = 'Veuillez saisir au moins une race bovine.'
return
}
if (isMerchandise.value) {
const selectedType = merchandiseTypesList.value.find(
(t) => String(t.id) === merchandiseForm.value.merchandiseTypeId
)
const isAutresType = selectedType?.code === MERCHANDISE_TYPE_CODES.AUTRES
const isGranuleType = selectedType?.code === MERCHANDISE_TYPE_CODES.GRANULE
if (isAutresType && !merchandiseForm.value.merchandiseDetail.trim()) {
showMerchandiseError.value = true
merchandiseErrorMessage.value = 'Veuillez préciser le type de marchandise.'
return
}
if (!isAutresType && !isGranuleType && merchandiseForm.value.selectedBuildingIds.length === 0) {
showMerchandiseError.value = true
merchandiseErrorMessage.value = 'Veuillez sélectionner au moins un bâtiment.'
return
}
if (isGranuleType) {
const hasAny = Object.values(merchandiseForm.value.selectedPelletBuildingIds)
.some((ids) => ids.length > 0)
if (!hasAny) {
showMerchandiseError.value = true
merchandiseErrorMessage.value = 'Veuillez sélectionner au moins un bâtiment.'
return
}
}
}
await receptionStore.updateReception(idReception, { await receptionStore.updateReception(idReception, {
...payload ...payload
}) })
@@ -820,6 +909,7 @@ async function validate() {
onMounted(async () => { onMounted(async () => {
await loadTypes() await loadTypes()
merchandiseTypesList.value = await getMerchandiseTypeList()
syncMerchandiseFlag() syncMerchandiseFlag()
formIsLoading.value = true formIsLoading.value = true
await loadUsers() await loadUsers()

View File

@@ -1,48 +1,64 @@
<template> <template>
<div class="flex items-center justify-between"> <WorkflowWaitingList
<div class="flex items-center gap-10"> title="listes des réceptions en attente"
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> :columns="columns"
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions en attente</h1> :items="receptionList ?? []"
</div> route-prefix="/reception"
</div> :show-actions="auth.isAdmin"
>
<div class="px-[86px]"> <template #cell-receptionDate="{ item }">
<div class="mt-6 border border-slate-200 mb-16"> {{ formatDate(item.receptionDate) }}
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> </template>
<div>Fournisseur</div> <template #actions="{ item }">
<div>Adresse</div> <Icon
<div>Type réception</div> name="mdi:delete-outline"
<div>Transporteur</div> size="24"
<div>Immatriculation</div> class="cursor-pointer text-red-500 hover:text-red-700"
</div> @click="confirmDelete(item)"
<div />
v-for="reception in receptionList" </template>
:key="reception.id" </WorkflowWaitingList>
class="grid grid-cols-5 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)"
@keydown.enter="goToReception(reception.id)"
>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ reception.carrier?.name }}</div>
<div>{{ reception.licensePlate }}</div>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data"; import type { ReceptionData } from '~/services/dto/reception-data'
import {getReceptionList} from "~/services/reception"; import { getReceptionList, deleteReception } from '~/services/reception'
import { useAuthStore } from '~/stores/auth'
const auth = useAuthStore()
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: 'carrier.name', label: 'Transporteur' },
{ key: 'licensePlate', label: 'Immatriculation' }
]
const receptionList = ref<ReceptionData[]>() const receptionList = ref<ReceptionData[]>()
const router = useRouter()
const goToReception = (id: number) => { const formatDate = (date: string | null) => {
router.push(`/reception/${id}`) 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 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.`
)
if (!confirmed) return
await deleteReception(reception.id)
receptionList.value = receptionList.value?.filter(r => r.id !== reception.id)
} }
onMounted(async () => { onMounted(async () => {

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

@@ -3,10 +3,9 @@
<div class="flex justify-between h-[52px] mb-[80px]"> <div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16"> <div class="flex flex-1 mr-16">
<UiStepper <UiStepper
:labels="SHIPMENT_STEP_LABELS" :labels="stepLabels"
:current-step="storeShipment?.currentStep ?? 0" :current-step="storeShipment?.currentStep ?? 0"
@select="handleStepSelect" @select="handleStepSelect"
/> />
</div> </div>
<UiButton <UiButton
@@ -17,67 +16,83 @@
</UiButton> </UiButton>
</div> </div>
<ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/> <ShipmentForm v-if="!storeShipment || storeShipment.currentStep === 0" ref="shipmentFormRef"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 1" mode="gross"/> <WorkflowWeight
v-if="storeShipment?.currentStep === 1"
ref="tareWeightRef"
mode="tare"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.tare"
:is-final="false"
:entity="storeShipment"
:get-weight-from-scale="getWeightShipment"
:update-entity="shipmentStore.updateShipment"
:load-entity="shipmentStore.loadShipment"
:clear-entity="shipmentStore.clearCurrent"
:build-receipt-filename="shipmentConfig.buildReceiptFilename"
/>
<ShipmentLoading v-if="storeShipment?.currentStep === 2"/> <ShipmentLoading v-if="storeShipment?.currentStep === 2"/>
<ShipmentWeight v-if="storeShipment?.currentStep === 3" mode="tare"/> <WorkflowWeight
v-if="storeShipment?.currentStep === 3"
ref="grossWeightRef"
mode="gross"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.gross"
:is-final="true"
:entity="storeShipment"
:get-weight-from-scale="getWeightShipment"
:update-entity="shipmentStore.updateShipment"
:load-entity="shipmentStore.loadShipment"
:clear-entity="shipmentStore.clearCurrent"
:build-receipt-filename="shipmentConfig.buildReceiptFilename"
/>
</div> </div>
</template> </template>
<script setup lang="ts">
import {SHIPMENT_STEP_LABELS} from "~/constants/steps"; <script setup lang="ts">
import {storeToRefs} from "pinia"; import { storeToRefs } from 'pinia'
import {useShipmentStore} from "~/stores/shipment"; import { useShipmentStore } from '~/stores/shipment'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
import { shipmentConfig } from '~/config/shipment.config'
import { getWeightShipment } from '~/services/shipment'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
const shipmentStore = useShipmentStore() const shipmentStore = useShipmentStore()
const {current: storeShipment} = storeToRefs(shipmentStore) 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)
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const resolveShipmentId = (param: unknown) => { watch(
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
watch (
() => route.params.id, () => route.params.id,
async (param) => { async (param) => {
const id = resolveShipmentId(param) const idStr = Array.isArray(param) ? param[0] : param
if (id === null) { if (!idStr) {
shipmentStore.clearCurrent() shipmentStore.clearCurrent()
return return
} }
await shipmentStore.loadShipment(id) const id = Number(idStr)
if (Number.isFinite(id)) {
await shipmentStore.loadShipment(id)
}
}, },
{immediate: true} { immediate: true }
) )
const saveAndHold = async () => { const saveAndHold = async () => {
if (shipmentFormRef.value) { if (shipmentFormRef.value) {
if (!shipmentFormRef.value.validateFields()) return
await shipmentFormRef.value.saveDraft() await shipmentFormRef.value.saveDraft()
} else {
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
} }
await router.push('/') await router.push('/')
} }
const handleStepSelect = async (step: number) => {
if (!shipmentStore.current) {
return
}
if (step === shipmentStore.current.currentStep) {
return
}
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: step
})
await shipmentStore.loadShipment(shipmentStore.current.id)
}
</script> </script>

View File

@@ -1,12 +1,14 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16"> <div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<div class="flex items-center justify-between gap-10 relative col-start-1 row-start-1"> <div class="flex items-center justify-between gap-10 relative col-start-1 row-start-1">
<div class="flex flex-row absolute -left-[60px] justify-between"> <div class="flex flex-row absolute -left-[60px] justify-between">
<Icon @click="router.push('/shipment/finish-shipment')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> <Icon @click="router.push('/shipment/finish-shipment')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
</div> </div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Expédition {{ form.identificationNumber }}</h1> <h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Expédition {{ form.identificationNumber }}</h1>
<Icon @click="printReceipt" name="mdi:printer-outline" size="44" class="cursor-pointer text-primary-500"/> <div class="bg-primary-500 p-1 rounded-md flex items-center" title="Imprimer" @click="printReceipt">
<Icon name="mdi:printer-outline" size="32" class="cursor-pointer text-white"/>
</div>
</div> </div>
<UiSelect <UiSelect
@@ -19,6 +21,7 @@
}))" }))"
:loading="isLoadingUsers" :loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2" wrapper-class="col-start-1 row-start-2"
required
/> />
<UiDateInput <UiDateInput
@@ -26,6 +29,7 @@
v-model="form.shipmentDate" v-model="form.shipmentDate"
label="Date d'expédition" label="Date d'expédition"
wrapper-class="col-start-1 row-start-3" wrapper-class="col-start-1 row-start-3"
required
/> />
<div class="col-start-1 row-start-4 h-[64px]"> <div class="col-start-1 row-start-4 h-[64px]">
@@ -41,6 +45,7 @@
value: String(type.id), value: String(type.id),
label: type.label label: type.label
}))" }))"
required
/> />
<UiNumberInput <UiNumberInput
id="shipment-type-quantity" id="shipment-type-quantity"
@@ -63,6 +68,7 @@
}))" }))"
:loading="isLoadingCustomers" :loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5" wrapper-class="col-start-1 row-start-5"
required
/> />
<UiSelect <UiSelect
@@ -72,6 +78,7 @@
:disabled="isLoadingCustomers || customerAddresses.length === 0" :disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse" label="Adresse"
wrapper-class="col-start-2 row-start-1" wrapper-class="col-start-2 row-start-1"
required
/> />
<UiSelect <UiSelect
@@ -84,6 +91,7 @@
}))" }))"
:loading="isLoadingTrucks" :loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2" wrapper-class="col-start-2 row-start-2"
required
/> />
<UiSelect <UiSelect
@@ -95,12 +103,14 @@
label: carrier.name label: carrier.name
}))" }))"
wrapper-class="col-start-2 row-start-3" wrapper-class="col-start-2 row-start-3"
required
/> />
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4"> <div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput <UiLicensePlateInput
v-model="form.licensePlate" v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate" v-model:allowAny="allowAnyLicensePlate"
required
/> />
</div> </div>
@@ -116,6 +126,7 @@
:loading="isLoadingVehicles" :loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0" :disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4" wrapper-class="col-start-2 row-start-4"
required
/> />
<div class="col-start-2 row-start-5 min-h-[72px]"> <div class="col-start-2 row-start-5 min-h-[72px]">
@@ -129,6 +140,7 @@
label: driver.name label: driver.name
}))" }))"
:loading="isLoadingDrivers" :loading="isLoadingDrivers"
required
/> />
</div> </div>
</div> </div>
@@ -137,18 +149,24 @@
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60"> <div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1 <h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer" class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50'" :class="[
@click="activeTab = 'weights'" activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
> hasTareWeightError ? '!text-red-500 !border-red-500' : ''
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'"
@click="activeTab = 'weightsEmpty'" @click="activeTab = 'weightsEmpty'"
> >
pesée à vide pesée à vide
</h1> </h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
</div> </div>
<div class="mb-12"> <div class="mb-12">
<update-weight <update-weight
@@ -170,6 +188,7 @@
<UiButton <UiButton
type="submit" type="submit"
class="text-xl mb-16 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 Valider
</UiButton> </UiButton>
@@ -220,7 +239,16 @@ const currentShipment = ref<ShipmentData | null>(null)
const selectedShipmentTypeId = ref('') const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0) const shipmentQuantity = ref<number | null>(0)
const allowAnyLicensePlate = ref(false) const allowAnyLicensePlate = ref(false)
const activeTab = ref<'weightsEmpty' | 'weights'>('weights') const submitted = ref(false)
const hasGrossWeightError = computed(() =>
submitted.value && (grossWeight.value.weight === null || grossWeight.value.weighedAt === null || grossWeight.value.dsd === null)
)
const hasTareWeightError = computed(() =>
submitted.value && (tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null)
)
const activeTab = ref<'weightsEmpty' | 'weights'>('weightsEmpty')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross')) const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare')) const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const formIsLoading = ref(false) const formIsLoading = ref(false)
@@ -376,7 +404,7 @@ function hydrateFromShipment(shipment: ShipmentData | null) {
isHydrating.value = true isHydrating.value = true
form.identificationNumber = shipment.identificationNumber ?? null form.identificationNumber = shipment.identificationNumber ?? null
form.licensePlate = shipment.licensePlate ?? '' form.licensePlate = shipment.licensePlate ?? ''
form.shipmentDate = shipment.shipmentDate ?? new Date().toISOString().slice(0, 10) form.shipmentDate = shipment.shipmentDate?.slice(0, 10) ?? new Date().toISOString().slice(0, 10)
form.userId = shipment.user?.id ? String(shipment.user.id) : form.userId form.userId = shipment.user?.id ? String(shipment.user.id) : form.userId
form.customerId = shipment.customer?.id ? String(shipment.customer.id) : '' form.customerId = shipment.customer?.id ? String(shipment.customer.id) : ''
form.addressId = shipment.address?.id ? String(shipment.address.id) : '' form.addressId = shipment.address?.id ? String(shipment.address.id) : ''
@@ -613,6 +641,14 @@ async function validate() {
return return
} }
const hasInvalidWeights =
grossWeight.value.weight === null || grossWeight.value.weighedAt === null || grossWeight.value.dsd === null ||
tareWeight.value.weight === null || tareWeight.value.weighedAt === null || tareWeight.value.dsd === null
if (hasInvalidWeights) {
return
}
await updateShipment(shipmentId.value, { await updateShipment(shipmentId.value, {
currentStep: currentShipment.value?.currentStep ?? 0, currentStep: currentShipment.value?.currentStep ?? 0,
...buildPayload() ...buildPayload()

View File

@@ -1,73 +1,88 @@
<template> <template>
<div class="flex items-center justify-between"> <WorkflowWaitingList
<div class="flex items-center gap-10"> title="listes des expéditions en attente"
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/> :columns="columns"
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions en attente</h1> :items="shipmentList ?? []"
</div> route-prefix="/shipment"
</div> :show-actions="auth.isAdmin"
>
<div class="px-[86px]"> <template #cell-shipmentDate="{ item }">
<div class="mt-6 border border-slate-200 mb-16 "> {{ formatDate(item.shipmentDate) }}
<div class="grid grid-cols-5 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> </template>
<div>Client</div> <template #cell-shipmentType="{ item }">
<div>Adresse</div> <template v-if="formatShipmentLines(item).length">
<div>Type d'expéditions</div> <div
<div>Transporteur</div> v-for="(line, index) in formatShipmentLines(item)"
<div>Immatriculation</div> :key="index"
</div> class="leading-5"
<div >
v-for="shipment in shipmentList" {{ line }}
:key="shipment.id"
class="grid grid-cols-5 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToShipment(shipment.id)"
@keydown.enter="goToShipment(shipment.id)"
>
<div>{{ shipment.customer?.name }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatShipmentLines(shipment).length">
<div
v-for="(line, index) in formatShipmentLines(shipment)"
:key="index"
class="leading-5"
>
{{ line }}
</div>
</template>
</div> </div>
<div>{{ shipment.carrier?.name }}</div> </template>
<div>{{ shipment.licensePlate }}</div> <template v-else></template>
</div> </template>
</div> <template #actions="{ item }">
</div> <Icon
name="mdi:delete-outline"
size="24"
class="cursor-pointer text-red-500 hover:text-red-700"
@click="confirmDelete(item)"
/>
</template>
</WorkflowWaitingList>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ShipmentData } from '~/services/dto/shipment-data'
import { getShipmentList, deleteShipment } from '~/services/shipment'
import { useAuthStore } from '~/stores/auth'
import type {ShipmentData} from "~/services/dto/shipment-data"; const auth = useAuthStore()
import {getShipmentList} from "~/services/shipment";
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: 'carrier.name', label: 'Transporteur' },
{ key: 'licensePlate', label: 'Immatriculation' }
]
const shipmentList = ref<ShipmentData[]>() const shipmentList = ref<ShipmentData[]>()
const router = useRouter()
const goToShipment = (id: number) => { const formatDate = (date: string | null) => {
router.push(`/shipment/${id}`) 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) => { const formatShipmentLines = (shipment: ShipmentData) => {
if (!shipment.shipmentType && shipment.nbBovinSend == null) { if (!shipment.shipmentType && shipment.nbBovinSend == null) {
return [] return []
} }
const label = typeof shipment.shipmentType === 'string' const label = typeof shipment.shipmentType === 'string'
? shipment.shipmentType ? shipment.shipmentType
: shipment.shipmentType?.label : shipment.shipmentType?.label
return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`] return [`${label ?? '—'} : ${shipment.nbBovinSend ?? '—'}`]
} }
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.`
)
if (!confirmed) return
await deleteShipment(shipment.id)
shipmentList.value = shipmentList.value?.filter(s => s.id !== shipment.id)
}
onMounted(async () => { onMounted(async () => {
shipmentList.value = await getShipmentList(false) shipmentList.value = await getShipmentList(false)
}) })

View File

@@ -1,7 +1,6 @@
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import type { AddressData } from '~/services/dto/address-data'
export interface AddressPayload { export interface AddressPayload {
label: string
street: string street: string
street2?: string | null street2?: string | null
postalCode: string postalCode: string

View File

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

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

@@ -1,6 +1,6 @@
export interface AddressData { export interface AddressData {
id: number id: number
label: string label?: string | null
street: string street: string
street2?: string | null street2?: string | null
postalCode: string postalCode: string
@@ -11,7 +11,7 @@ export interface AddressData {
export interface AddressFormData { export interface AddressFormData {
id?: number | null id?: number | null
label: string label?: string | null
street: string street: string
street2?: string | null street2?: string | null
postalCode: string postalCode: string

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 { export interface BuildingCaseData {
id: number id: number
caseNumber: number | null caseNumber: number | null
code: string | null code: string | null
capacity: number | null capacity: number | null
statut?: BuildingCaseStatusData | null statut?: { label: string; couleur: string } | null
building?: BuildingSummary | null
bovines: BovineData[]
} }

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import type { AddressFormData } from "~/services/dto/address-data" import type { AddressFormData } from "~/services/dto/address-data"
import type { UserData } from "~/services/dto/user-data"
export type CustomerAddresses = AddressFormData[] | string[] export type CustomerAddresses = AddressFormData[] | string[]
@@ -7,6 +8,7 @@ export interface CustomerData {
name: string name: string
phone?: string | null phone?: string | null
email?: string | null email?: string | null
createdBy?: UserData | null
addresses: CustomerAddresses addresses: CustomerAddresses
} }

View File

@@ -1,4 +1,5 @@
import type { AddressFormData } from "~/services/dto/address-data" import type { AddressFormData } from "~/services/dto/address-data"
import type { UserData } from "~/services/dto/user-data"
export type SupplierAddresses = AddressFormData[] | string[] export type SupplierAddresses = AddressFormData[] | string[]
@@ -7,6 +8,7 @@ export interface SupplierData {
name: string name: string
email?: string | null email?: string | null
phone?: string | null phone?: string | null
createdBy?: UserData | null
addresses: SupplierAddresses addresses: SupplierAddresses
} }

View File

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

16
frontend/services/geo.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface CommuneData {
nom: string
code: string
}
export async function getCommunesByPostalCode(postalCode: string): Promise<CommuneData[]> {
const config = useRuntimeConfig()
const base = config.public.geoApiBase
try {
return await $fetch<CommuneData[]>(`${base}/communes`, {
params: { codePostal: postalCode, fields: 'nom', format: 'json' }
})
} catch {
return []
}
}

View File

@@ -1,41 +1,22 @@
import {useApi} from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import type {ReceptionData, ReceptionPayload} from '~/services/dto/reception-data' import { createWorkflowService } from '~/services/workflow-service'
import type {WeightData} from '~/services/dto/weight-data' import type { ReceptionData, ReceptionPayload } from '~/services/dto/reception-data'
import type { WeightData } from '~/services/dto/weight-data'
export async function getReceptionList(isValid: boolean|null = null) { const service = createWorkflowService<ReceptionData, ReceptionPayload>('receptions', 'reception')
export const getReceptionList = service.getList
export const getReception = service.get
export const createReception = service.create
export const updateReception = service.update
export const getWeight = service.getWeight as () => Promise<WeightData>
export async function deleteReception(id: number) {
const api = useApi() const api = useApi()
const query = isValid !== null ? { isValid: isValid} : {} return api.delete(`receptions/${id}`, {}, {
return api.get<ReceptionData[]>('receptions', query, { toastSuccessKey: 'success.reception.delete',
toastErrorKey: 'errors.reception.list' toastErrorKey: 'errors.reception.delete'
}) })
} }
export { service as receptionService }
export async function getReception(id: number) {
const api = useApi()
return api.get<ReceptionData>(`receptions/${id}`, {}, {
toastErrorKey: 'errors.reception.fetch'
})
}
export async function createReception(payload: ReceptionPayload = {}) {
const api = useApi()
return api.post<ReceptionData>('receptions', payload, {
toastErrorKey: 'errors.reception.create'
})
}
export async function updateReception(id: number, payload: ReceptionPayload) {
const api = useApi()
return api.patch<ReceptionData>(`receptions/${id}`, payload, {
toastErrorKey: 'errors.reception.update',
toastSuccessKey: 'success.reception.update'
})
}
export async function getWeight(): Promise<WeightData> {
const api = useApi()
return api.get<WeightData>('receptions/weigh', {}, {
toastErrorKey: 'errors.reception.weigh'
})
}

View File

@@ -1,40 +1,22 @@
import {useApi} from '~/composables/useApi' import { createWorkflowService } from '~/services/workflow-service'
import type {ShipmentData, ShipmentPayload} from '~/services/dto/shipment-data' import type { ShipmentData, ShipmentPayload } from '~/services/dto/shipment-data'
import type {WeightData} from '~/services/dto/weight-data' import type { WeightData } from '~/services/dto/weight-data'
export async function getShipmentList(isValid: boolean|null = null) { const service = createWorkflowService<ShipmentData, ShipmentPayload>('shipments', 'shipment')
export const getShipmentList = service.getList
export const getShipment = service.get
export const createShipment = service.create
export const updateShipment = service.update
export const getWeightShipment = service.getWeight as () => Promise<WeightData>
export async function deleteShipment(id: number) {
const { useApi } = await import('~/composables/useApi')
const api = useApi() const api = useApi()
const query = isValid !== null ? { isValid: isValid} : {} return api.delete(`shipments/${id}`, {}, {
return api.get<ShipmentData[]>('shipments', query, { toastSuccessKey: 'success.shipment.delete',
toastErrorKey: 'errors.shipment.list' toastErrorKey: 'errors.shipment.delete'
}) })
} }
export async function getShipment(id: number) { export { service as shipmentService }
const api = useApi()
return api.get<ShipmentData>(`shipments/${id}`, {}, {
toastErrorKey: 'errors.shipment.fetch'
})
}
export async function createShipment(payload: ShipmentPayload = {}) {
const api = useApi()
return api.post<ShipmentData>('shipments', payload, {
toastErrorKey: 'errors.shipment.create'
})
}
export async function updateShipment(id: number, payload: ShipmentPayload) {
const api = useApi()
return api.patch<ShipmentData>(`shipments/${id}`, payload, {
toastErrorKey: 'errors.shipment.update',
toastSuccessKey: 'success.shipment.update'
})
}
export async function getWeightShipment(): Promise<WeightData> {
const api = useApi()
return api.get<WeightData>('shipments/weigh', {}, {
toastErrorKey: 'errors.shipment.weigh'
})
}

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,8 +1,5 @@
import { useApi } from '~/composables/useApi' import { useApi } from '~/composables/useApi'
import type {ReceptionData, ReceptionPayload, WeightEntryData} from '~/services/dto/reception-data' import type { WeightEntryData } from '~/services/dto/reception-data'
import type {Ref} from "vue";
import type {ShipmentData, ShipmentPayload} from "~/services/dto/shipment-data";
import type {WeighingMode} from "~/composables/useWeighing";
export type WeightPayload = { export type WeightPayload = {
reception?: string reception?: string
@@ -13,29 +10,15 @@ export type WeightPayload = {
weighedAt: string | null weighedAt: string | null
} }
export async function createWeight(payload: WeightPayload) { export async function createWeight(payload: WeightPayload | Record<string, any>) {
const api = useApi() const api = useApi()
return api.post<WeightEntryData>('weights', payload) return api.post<WeightEntryData>('weights', payload)
} }
export async function updateWeight(id: number, payload: Partial<WeightPayload>) { export async function updateWeight(id: number, payload: Partial<WeightPayload>) {
const api = useApi() const api = useApi()
return api.patch<WeightEntryData>(`weights/${id}`, payload,{ return api.patch<WeightEntryData>(`weights/${id}`, payload, {
toastErrorKey: 'errors.weight.update', toastErrorKey: 'errors.weight.update',
toastSuccessKey: 'success.weight.update' toastSuccessKey: 'success.weight.update'
}) })
} }
export type UseWeighingShipmentOptions = {
modeShipment: WeighingMode
shipment: Ref<ShipmentData | null>
updateShipment: (id: number, payload: ShipmentPayload) => Promise<ShipmentData | null>
loadShipment?: (id: number) => Promise<ShipmentData | null>
}
export type UseWeighingOptions = {
mode: WeighingMode
reception: Ref<ReceptionData | null>
updateReception: (id: number, payload: ReceptionPayload) => Promise<ReceptionData | null>
loadReception?: (id: number) => Promise<ReceptionData | null>
}

View File

@@ -0,0 +1,55 @@
import { useApi } from '~/composables/useApi'
import type { WeightData } from '~/services/dto/weight-data'
export interface WorkflowService<TEntity, TPayload> {
getList: (isValid?: boolean | null) => Promise<TEntity[]>
get: (id: number) => Promise<TEntity>
create: (payload: TPayload) => Promise<TEntity>
update: (id: number, payload: TPayload) => Promise<TEntity>
getWeight: () => Promise<WeightData>
}
export function createWorkflowService<TEntity, TPayload>(
apiResource: string,
toastPrefix: string
): WorkflowService<TEntity, TPayload> {
const getList = async (isValid: boolean | null = null): Promise<TEntity[]> => {
const api = useApi()
const query = isValid !== null ? { isValid } : {}
return api.get<TEntity[]>(apiResource, query, {
toastErrorKey: `errors.${toastPrefix}.list`
})
}
const get = async (id: number): Promise<TEntity> => {
const api = useApi()
return api.get<TEntity>(`${apiResource}/${id}`, {}, {
toastErrorKey: `errors.${toastPrefix}.fetch`
})
}
const create = async (payload: TPayload): Promise<TEntity> => {
const api = useApi()
return api.post<TEntity>(apiResource, payload, {
toastSuccessKey: `success.${toastPrefix}.create`,
toastErrorKey: `errors.${toastPrefix}.create`
})
}
const update = async (id: number, payload: TPayload): Promise<TEntity> => {
const api = useApi()
return api.patch<TEntity>(`${apiResource}/${id}`, payload, {
toastErrorKey: `errors.${toastPrefix}.update`,
toastSuccessKey: `success.${toastPrefix}.update`
})
}
const getWeight = async (): Promise<WeightData> => {
const api = useApi()
return api.get<WeightData>(`${apiResource}/weigh`, {}, {
toastErrorKey: `errors.${toastPrefix}.weigh`
})
}
return { getList, get, create, update, getWeight }
}

View File

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

View File

@@ -1,59 +1,19 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useWorkflowStoreLogic } from '~/stores/workflow-store'
import { receptionService } from '~/services/reception'
import type { ReceptionData, ReceptionPayload } from '~/services/dto/reception-data' import type { ReceptionData, ReceptionPayload } from '~/services/dto/reception-data'
import { createReception, getReception, updateReception } from '~/services/reception'
const isReceptionData = (value: unknown): value is ReceptionData => { export const useReceptionStore = defineStore('reception', () => {
return Boolean(value && typeof value === 'object' && 'id' in value) const { current, isLoading, setCurrent, clearCurrent, load, create, update } =
} useWorkflowStoreLogic<ReceptionData, ReceptionPayload>(receptionService)
export const useReceptionStore = defineStore('reception', { return {
state: () => ({ current,
current: null as ReceptionData | null, isLoading,
isLoading: false setCurrent,
}), clearCurrent,
actions: { loadReception: load,
setCurrent(reception: ReceptionData | null) { createReception: create,
this.current = reception updateReception: update
},
clearCurrent() {
this.current = null
},
async loadReception(id: number) {
this.isLoading = true
const result = await getReception(id).finally(() => {
this.isLoading = false
})
if (!isReceptionData(result)) {
this.current = null
return null
}
this.current = result
return result
},
async createReception(payload: ReceptionPayload = {}) {
this.isLoading = true
const result = await createReception(payload).finally(() => {
this.isLoading = false
})
if (!isReceptionData(result)) {
return null
}
this.current = result
return result
},
async updateReception(id: number, payload: ReceptionPayload) {
this.isLoading = true
const result = await updateReception(id, payload).finally(() => {
this.isLoading = false
})
if (!isReceptionData(result)) {
return null
}
this.current = result
return result
} }
}
}) })

View File

@@ -1,58 +1,19 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import type {ShipmentData, ShipmentPayload} from "~/services/dto/shipment-data"; import { useWorkflowStoreLogic } from '~/stores/workflow-store'
import {createShipment, getShipment, updateShipment} from "~/services/shipment"; import { shipmentService } from '~/services/shipment'
import type { ShipmentData, ShipmentPayload } from '~/services/dto/shipment-data'
const isShipmentData = (value: unknown): value is ShipmentData => { export const useShipmentStore = defineStore('shipment', () => {
return Boolean(value && typeof value === 'object' && 'id' in value) const { current, isLoading, setCurrent, clearCurrent, load, create, update } =
} useWorkflowStoreLogic<ShipmentData, ShipmentPayload>(shipmentService)
export const useShipmentStore = defineStore('shipment', {
state: () => ({
current: null as ShipmentData | null,
isLoading: false
}),
actions: {
setCurrent(shipment: ShipmentData | null) {
this.current = shipment
},
clearCurrent() {
this.current = null
},
async loadShipment(id: number) {
this.isLoading = true
const result = await getShipment(id).finally(() => {
this.isLoading = false
})
if (!isShipmentData(result)) {
this.current = null
return null
}
this.current = result return {
return result current,
}, isLoading,
async createShipment(payload: ShipmentPayload = {}) { setCurrent,
this.isLoading = true clearCurrent,
const result = await createShipment(payload).finally(() => { loadShipment: load,
this.isLoading = false createShipment: create,
}) updateShipment: update
if (!isShipmentData(result)) {
return null
}
this.current = result
return result
},
async updateShipment(id: number, payload: ShipmentPayload) {
this.isLoading = true
const result = await updateShipment(id, payload).finally(() => {
this.isLoading = false
})
if (!isShipmentData(result)) {
return null
}
this.current = result
return result
}
} }
}) })

View File

@@ -0,0 +1,59 @@
import type { WorkflowService } from '~/services/workflow-service'
const isEntityData = (value: unknown): value is { id: number } => {
return Boolean(value && typeof value === 'object' && 'id' in value)
}
export function useWorkflowStoreLogic<TEntity extends { id: number }, TPayload>(
service: WorkflowService<TEntity, TPayload>
) {
const current = ref<TEntity | null>(null)
const isLoading = ref(false)
const setCurrent = (entity: TEntity | null) => {
current.value = entity
}
const clearCurrent = () => {
current.value = null
}
const load = async (id: number) => {
isLoading.value = true
const result = await service.get(id).finally(() => {
isLoading.value = false
})
if (!isEntityData(result)) {
current.value = null
return null
}
current.value = result as any
return result
}
const create = async (payload: TPayload) => {
isLoading.value = true
const result = await service.create(payload).finally(() => {
isLoading.value = false
})
if (!isEntityData(result)) {
return null
}
current.value = result as any
return result
}
const update = async (id: number, payload: TPayload) => {
isLoading.value = true
const result = await service.update(id, payload).finally(() => {
isLoading.value = false
})
if (!isEntityData(result)) {
return null
}
current.value = result as any
return result
}
return { current, isLoading, setCurrent, clearCurrent, load, create, update }
}

View File

@@ -0,0 +1,48 @@
import type { WeightEntryData } from '~/services/dto/reception-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { UserData } from '~/services/dto/user-data'
import type { AddressData } from '~/services/dto/address-data'
import type { DriverData } from '~/services/dto/driver-data'
export interface WorkflowEntity {
id: number
identificationNumber?: string | null
licensePlate: string | null
currentStep: number
isValid: boolean
weights?: WeightEntryData[] | null
carrier?: CarrierData | null
truck?: TruckData | null
user?: UserData | null
address?: AddressData | null
driver?: DriverData | null
}
export interface WorkflowPayload {
licensePlate?: string | null
currentStep?: number
isValid?: boolean
carrier?: string | null
truck?: string | null
user?: string | null
address?: string | null
driver?: string | null
}
export interface StepDefinition {
label: string
weighingMode?: 'gross' | 'tare'
isFinal?: boolean
}
export interface WorkflowConfig {
entityName: 'reception' | 'shipment'
apiResource: string
steps: StepDefinition[]
weighingLabels: Record<'gross' | 'tare', string>
buildReceiptFilename: (entity: WorkflowEntity) => string
routePrefix: string
toastPrefix: string
dateField: string
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260318103644 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 address ALTER label DROP NOT NULL');
$this->addSql('ALTER INDEX idx_ea9e2a42f8d859df RENAME TO IDX_2068337FF8D859DF');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE address ALTER label SET NOT NULL');
$this->addSql('ALTER INDEX idx_2068337ff8d859df RENAME TO idx_ea9e2a42f8d859df');
}
}

View File

@@ -0,0 +1,41 @@
<?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 Version20260318104205 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 customer ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE customer ADD CONSTRAINT FK_81398E09DE12AB56 FOREIGN KEY (created_by) REFERENCES public."user" (id) NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_81398E09DE12AB56 ON customer (created_by)');
$this->addSql('ALTER TABLE supplier ADD created_by INT DEFAULT NULL');
$this->addSql('ALTER TABLE supplier ADD CONSTRAINT FK_9B2A6C7EDE12AB56 FOREIGN KEY (created_by) REFERENCES public."user" (id) NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_9B2A6C7EDE12AB56 ON supplier (created_by)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE customer DROP CONSTRAINT FK_81398E09DE12AB56');
$this->addSql('DROP INDEX IDX_81398E09DE12AB56');
$this->addSql('ALTER TABLE customer DROP created_by');
$this->addSql('ALTER TABLE supplier DROP CONSTRAINT FK_9B2A6C7EDE12AB56');
$this->addSql('DROP INDEX IDX_9B2A6C7EDE12AB56');
$this->addSql('ALTER TABLE supplier DROP created_by');
}
}

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

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

View File

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

View File

@@ -18,7 +18,6 @@ use App\Entity\MerchandiseType;
use App\Entity\PelletType; use App\Entity\PelletType;
use App\Entity\ReceptionType; use App\Entity\ReceptionType;
use App\Entity\ShipmentType; use App\Entity\ShipmentType;
use App\Entity\Statut;
use App\Entity\Supplier; use App\Entity\Supplier;
use App\Entity\Truck; use App\Entity\Truck;
use App\Entity\Vehicle; use App\Entity\Vehicle;
@@ -216,6 +215,7 @@ class SeedCommand extends Command
['label' => 'Bâtiment 1', 'code' => 'B1'], ['label' => 'Bâtiment 1', 'code' => 'B1'],
['label' => 'Bâtiment 2', 'code' => 'B2'], ['label' => 'Bâtiment 2', 'code' => 'B2'],
['label' => 'Bâtiment 3', 'code' => 'B3'], ['label' => 'Bâtiment 3', 'code' => 'B3'],
['label' => 'Zone tampon', 'code' => 'ZT'],
]; ];
foreach ($buildings as $buildingData) { foreach ($buildings as $buildingData) {
$this->upsertByCode(Building::class, $buildingData['code'], static function (Building $entity) use ($buildingData) { $this->upsertByCode(Building::class, $buildingData['code'], static function (Building $entity) use ($buildingData) {
@@ -229,24 +229,6 @@ class SeedCommand extends Command
private function seedBuildingInfrastructure(): void private function seedBuildingInfrastructure(): void
{ {
$statusByCode = [];
$statusRows = [
['label' => 'Libre', 'code' => 'LB', 'color' => '#A3B18A'],
['label' => 'Occupé', 'code' => 'OC', 'color' => '#3A506B'],
['label' => 'Malade', 'code' => 'ML', 'color' => '#E07A5F'],
];
foreach ($statusRows as $statusRow) {
/** @var Statut $status */
$status = $this->upsertByCode(Statut::class, $statusRow['code'], static function (Statut $entity) use ($statusRow) {
$entity
->setLabel($statusRow['label'])
->setCode($statusRow['code'])
->setColor($statusRow['color'])
;
});
$statusByCode[$statusRow['code']] = $status;
}
$buildingRepo = $this->entityManager->getRepository(Building::class); $buildingRepo = $this->entityManager->getRepository(Building::class);
$layoutByBuildingCode = []; $layoutByBuildingCode = [];
$layoutRows = [ $layoutRows = [
@@ -273,25 +255,15 @@ class SeedCommand extends Command
} }
$caseRows = [ $caseRows = [
['buildingCode' => 'B1', 'from' => 1, 'to' => 12, 'status' => 'LB'], ['buildingCode' => 'B1', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 13, 'to' => 24, 'status' => 'OC'], ['buildingCode' => 'B2', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 25, 'to' => 32, 'status' => 'ML'], ['buildingCode' => 'B3', 'from' => 1, 'to' => 44],
['buildingCode' => 'B1', 'from' => 33, 'to' => 44, 'status' => 'LB'],
['buildingCode' => 'B2', 'from' => 1, 'to' => 10, 'status' => 'OC'],
['buildingCode' => 'B2', 'from' => 11, 'to' => 22, 'status' => 'LB'],
['buildingCode' => 'B2', 'from' => 23, 'to' => 30, 'status' => 'ML'],
['buildingCode' => 'B2', 'from' => 31, 'to' => 44, 'status' => 'OC'],
['buildingCode' => 'B3', 'from' => 1, 'to' => 8, 'status' => 'ML'],
['buildingCode' => 'B3', 'from' => 9, 'to' => 20, 'status' => 'LB'],
['buildingCode' => 'B3', 'from' => 21, 'to' => 34, 'status' => 'OC'],
['buildingCode' => 'B3', 'from' => 35, 'to' => 44, 'status' => 'ML'],
]; ];
$caseByCode = []; $caseByCode = [];
foreach ($caseRows as $caseRow) { foreach ($caseRows as $caseRow) {
$building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]); $building = $buildingRepo->findOneBy(['code' => $caseRow['buildingCode']]);
$status = $statusByCode[$caseRow['status']] ?? null; if (!$building instanceof Building) {
if (!$building instanceof Building || !$status instanceof Statut) {
continue; continue;
} }
@@ -299,13 +271,12 @@ class SeedCommand extends Command
$code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber); $code = sprintf('%s-C%d', $caseRow['buildingCode'], $caseNumber);
/** @var BuildingCase $buildingCase */ /** @var BuildingCase $buildingCase */
$buildingCase = $this->upsertByCode(BuildingCase::class, $code, static function (BuildingCase $entity) use ($code, $caseNumber, $building, $status) { $buildingCase = $this->upsertByCode(BuildingCase::class, $code, static function (BuildingCase $entity) use ($code, $caseNumber, $building) {
$entity $entity
->setCode($code) ->setCode($code)
->setCaseNumber($caseNumber) ->setCaseNumber($caseNumber)
->setCapacity(15) ->setCapacity(15)
->setIdBuilding($building) ->setIdBuilding($building)
->setStatut($status)
; ;
}); });
$caseByCode[$code] = $buildingCase; $caseByCode[$code] = $buildingCase;
@@ -544,7 +515,6 @@ class SeedCommand extends Command
'phone' => '05.49.20.09.10', 'phone' => '05.49.20.09.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LIOT CHATELLERAULT',
'street' => "14 Allée d'Argenson", 'street' => "14 Allée d'Argenson",
'street2' => 'ZI Nord', 'street2' => 'ZI Nord',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -559,7 +529,6 @@ class SeedCommand extends Command
'phone' => '05.49.02.65.27', 'phone' => '05.49.02.65.27',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -574,7 +543,6 @@ class SeedCommand extends Command
'phone' => '06.80.14.18.82', 'phone' => '06.80.14.18.82',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL DES GONNIERES',
'street' => "27 Route d'Ingrandes", 'street' => "27 Route d'Ingrandes",
'street2' => 'Les Gonnières', 'street2' => 'Les Gonnières',
'postalCode' => '86220', 'postalCode' => '86220',
@@ -589,7 +557,6 @@ class SeedCommand extends Command
'phone' => '05.49.86.17.95', 'phone' => '05.49.86.17.95',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL LESIGNY BABY',
'street' => '2 Lieu Dit Les Bouquins', 'street' => '2 Lieu Dit Les Bouquins',
'street2' => null, 'street2' => null,
'postalCode' => '86270', 'postalCode' => '86270',
@@ -604,7 +571,6 @@ class SeedCommand extends Command
'phone' => '03.85.24.25.50', 'phone' => '03.85.24.25.50',
'addresses' => [ 'addresses' => [
[ [
'label' => 'FEDER',
'street' => 'Molaise', 'street' => 'Molaise',
'street2' => null, 'street2' => null,
'postalCode' => '71120', 'postalCode' => '71120',
@@ -619,7 +585,6 @@ class SeedCommand extends Command
'phone' => '05.49.86.57.24', 'phone' => '05.49.86.57.24',
'addresses' => [ 'addresses' => [
[ [
'label' => "GAEC DE L'ESPOIR",
'street' => 'La Moujonnerie', 'street' => 'La Moujonnerie',
'street2' => null, 'street2' => null,
'postalCode' => '86450', 'postalCode' => '86450',
@@ -634,7 +599,6 @@ class SeedCommand extends Command
'phone' => '05.49.23.51.66', 'phone' => '05.49.23.51.66',
'addresses' => [ 'addresses' => [
[ [
'label' => 'GRAVELEAU',
'street' => '3, Le Jeu', 'street' => '3, Le Jeu',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -649,7 +613,6 @@ class SeedCommand extends Command
'phone' => '05.49.52.77.10', 'phone' => '05.49.52.77.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY',
'street' => 'Ferme de Geniec', 'street' => 'Ferme de Geniec',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -664,7 +627,6 @@ class SeedCommand extends Command
'phone' => '05.65.67.89.46', 'phone' => '05.65.67.89.46',
'addresses' => [ 'addresses' => [
[ [
'label' => 'NATERA',
'street' => 'Bd des Balquières', 'street' => 'Bd des Balquières',
'street2' => 'BP 3220', 'street2' => 'BP 3220',
'postalCode' => '12032', 'postalCode' => '12032',
@@ -674,12 +636,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'SCEA des Bariollières', 'name' => 'SCEA DES BARIOLLIÈRES',
'email' => 'elisregnier@gmail.com', 'email' => 'elisregnier@gmail.com',
'phone' => '06.09.37.65.61', 'phone' => '06.09.37.65.61',
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA des Bariollières',
'street' => '2 rue des Barriollières', 'street' => '2 rue des Barriollières',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -694,7 +655,6 @@ class SeedCommand extends Command
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA SENE',
'street' => '3 Route de la Roche Posay', 'street' => '3 Route de la Roche Posay',
'street2' => 'Les Girouettes', 'street2' => 'Les Girouettes',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -709,7 +669,6 @@ class SeedCommand extends Command
'phone' => '02.51.67.17.98', 'phone' => '02.51.67.17.98',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'La Blanchardière', 'street' => 'La Blanchardière',
'street2' => null, 'street2' => null,
'postalCode' => '44522', 'postalCode' => '44522',
@@ -724,7 +683,6 @@ class SeedCommand extends Command
'phone' => '05.49.19.44.33', 'phone' => '05.49.19.44.33',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TRICHERIE COOPERATIVE',
'street' => 'B.P n°2', 'street' => 'B.P n°2',
'street2' => null, 'street2' => null,
'postalCode' => '86490', 'postalCode' => '86490',
@@ -734,12 +692,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'TURPAULT Muriel', 'name' => 'TURPAULT MURIEL',
'email' => null, 'email' => null,
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'TURPAULT Muriel',
'street' => '23Bis Rue Marcel Pagnol', 'street' => '23Bis Rue Marcel Pagnol',
'street2' => null, 'street2' => null,
'postalCode' => '86100', 'postalCode' => '86100',
@@ -748,6 +705,34 @@ class SeedCommand extends Command
], ],
], ],
], ],
[
'name' => 'EARL DE LA MENAUDIERE',
'email' => 'frederic.doussineau@orange.fr',
'phone' => '0675446004',
'addresses' => [
[
'street' => '1 la menaudière',
'street2' => null,
'postalCode' => '86450',
'city' => 'LEIGNE LES BOIS ',
'countryCode' => 'FR',
],
],
],
[
'name' => 'SARL ERBS',
'email' => 'touillet.jacques@yahoo.fr',
'phone' => '0675030304',
'addresses' => [
[
'street' => 'les rodières ',
'street2' => null,
'postalCode' => '86230',
'city' => 'Sérigny',
'countryCode' => 'FR',
],
],
],
]; ];
foreach ($suppliers as $supplierData) { foreach ($suppliers as $supplierData) {
@@ -780,7 +765,6 @@ class SeedCommand extends Command
'email' => 'eurl.arnault86@orange.fr', 'email' => 'eurl.arnault86@orange.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -795,7 +779,6 @@ class SeedCommand extends Command
'email' => 'sandra.robineaux@covilim.com', 'email' => 'sandra.robineaux@covilim.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'COVILIM',
'street' => 'Rue de Nexon', 'street' => 'Rue de Nexon',
'street2' => null, 'street2' => null,
'postalCode' => '87000', 'postalCode' => '87000',
@@ -805,12 +788,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'Les producteurs de la marche (LPM)', 'name' => 'LES PRODUCTEURS DE LA MARCHE (LPM)',
'phone' => '05.55.63.04.53', 'phone' => '05.55.63.04.53',
'email' => 'f.legalliard@lpmcoop.fr', 'email' => 'f.legalliard@lpmcoop.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'Les producteurs de la marche (LPM)',
'street' => 'Malonze', 'street' => 'Malonze',
'street2' => null, 'street2' => null,
'postalCode' => '23300', 'postalCode' => '23300',
@@ -825,7 +807,6 @@ class SeedCommand extends Command
'email' => 'contact86@lortholarybetail.com', 'email' => 'contact86@lortholarybetail.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY BETAIL',
'street' => 'FERME DE GENIEC', 'street' => 'FERME DE GENIEC',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -840,7 +821,6 @@ class SeedCommand extends Command
'email' => 'scouillaud@terrena.fr', 'email' => 'scouillaud@terrena.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'LA NOELLE', 'street' => 'LA NOELLE',
'street2' => 'BP 20199', 'street2' => 'BP 20199',
'postalCode' => '44155', 'postalCode' => '44155',
@@ -930,7 +910,7 @@ class SeedCommand extends Command
{ {
$addressRepo = $this->entityManager->getRepository(Address::class); $addressRepo = $this->entityManager->getRepository(Address::class);
$address = $addressRepo->findOneBy([ $address = $addressRepo->findOneBy([
'label' => $addressData['label'], 'street' => $addressData['street'],
'postalCode' => $addressData['postalCode'], 'postalCode' => $addressData['postalCode'],
]); ]);
if (!$address) { if (!$address) {
@@ -940,7 +920,6 @@ class SeedCommand extends Command
++$this->updated; ++$this->updated;
} }
$address $address
->setLabel($addressData['label'])
->setStreet($addressData['street']) ->setStreet($addressData['street'])
->setStreet2($addressData['street2']) ->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode']) ->setPostalCode($addressData['postalCode'])

View File

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

View File

@@ -54,6 +54,7 @@ class ReferenceFixtures extends Fixture
['label' => 'Bâtiment 1', 'code' => 'B1'], ['label' => 'Bâtiment 1', 'code' => 'B1'],
['label' => 'Bâtiment 2', 'code' => 'B2'], ['label' => 'Bâtiment 2', 'code' => 'B2'],
['label' => 'Bâtiment 3', 'code' => 'B3'], ['label' => 'Bâtiment 3', 'code' => 'B3'],
['label' => 'Zone tampon', 'code' => 'ZT'],
]; ];
foreach ($buildings as $buildingData) { foreach ($buildings as $buildingData) {
$building = new Building() $building = new Building()
@@ -107,7 +108,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.20.09.10', 'phone' => '05.49.20.09.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LIOT CHATELLERAULT',
'street' => "14 Allée d'Argenson", 'street' => "14 Allée d'Argenson",
'street2' => 'ZI Nord', 'street2' => 'ZI Nord',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -122,7 +122,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.02.65.27', 'phone' => '05.49.02.65.27',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -137,7 +136,6 @@ class ReferenceFixtures extends Fixture
'phone' => '06.80.14.18.82', 'phone' => '06.80.14.18.82',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL DES GONNIERES',
'street' => "27 Route d'Ingrandes", 'street' => "27 Route d'Ingrandes",
'street2' => 'Les Gonnières', 'street2' => 'Les Gonnières',
'postalCode' => '86220', 'postalCode' => '86220',
@@ -152,7 +150,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.86.17.95', 'phone' => '05.49.86.17.95',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL LESIGNY BABY',
'street' => '2 Lieu Dit Les Bouquins', 'street' => '2 Lieu Dit Les Bouquins',
'street2' => null, 'street2' => null,
'postalCode' => '86270', 'postalCode' => '86270',
@@ -167,7 +164,6 @@ class ReferenceFixtures extends Fixture
'phone' => '03.85.24.25.50', 'phone' => '03.85.24.25.50',
'addresses' => [ 'addresses' => [
[ [
'label' => 'FEDER',
'street' => 'Molaise', 'street' => 'Molaise',
'street2' => null, 'street2' => null,
'postalCode' => '71120', 'postalCode' => '71120',
@@ -182,7 +178,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.86.57.24', 'phone' => '05.49.86.57.24',
'addresses' => [ 'addresses' => [
[ [
'label' => "GAEC DE L'ESPOIR",
'street' => 'La Moujonnerie', 'street' => 'La Moujonnerie',
'street2' => null, 'street2' => null,
'postalCode' => '86450', 'postalCode' => '86450',
@@ -197,7 +192,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.23.51.66', 'phone' => '05.49.23.51.66',
'addresses' => [ 'addresses' => [
[ [
'label' => 'GRAVELEAU',
'street' => '3, Le Jeu', 'street' => '3, Le Jeu',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -212,7 +206,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.52.77.10', 'phone' => '05.49.52.77.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY',
'street' => 'Ferme de Geniec', 'street' => 'Ferme de Geniec',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -227,7 +220,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.65.67.89.46', 'phone' => '05.65.67.89.46',
'addresses' => [ 'addresses' => [
[ [
'label' => 'NATERA',
'street' => 'Bd des Balquières', 'street' => 'Bd des Balquières',
'street2' => 'BP 3220', 'street2' => 'BP 3220',
'postalCode' => '12032', 'postalCode' => '12032',
@@ -237,12 +229,11 @@ class ReferenceFixtures extends Fixture
], ],
], ],
[ [
'name' => 'SCEA des Bariollières', 'name' => 'SCEA DES BARIOLLIÈRES',
'email' => 'elisregnier@gmail.com', 'email' => 'elisregnier@gmail.com',
'phone' => '06.09.37.65.61', 'phone' => '06.09.37.65.61',
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA des Bariollières',
'street' => '2 rue des Barriollières', 'street' => '2 rue des Barriollières',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -257,7 +248,6 @@ class ReferenceFixtures extends Fixture
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA SENE',
'street' => '3 Route de la Roche Posay', 'street' => '3 Route de la Roche Posay',
'street2' => 'Les Girouettes', 'street2' => 'Les Girouettes',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -272,7 +262,6 @@ class ReferenceFixtures extends Fixture
'phone' => '02.51.67.17.98', 'phone' => '02.51.67.17.98',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'La Blanchardière', 'street' => 'La Blanchardière',
'street2' => null, 'street2' => null,
'postalCode' => '44522', 'postalCode' => '44522',
@@ -287,7 +276,6 @@ class ReferenceFixtures extends Fixture
'phone' => '05.49.19.44.33', 'phone' => '05.49.19.44.33',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TRICHERIE COOPERATIVE',
'street' => 'B.P n°2', 'street' => 'B.P n°2',
'street2' => null, 'street2' => null,
'postalCode' => '86490', 'postalCode' => '86490',
@@ -297,12 +285,11 @@ class ReferenceFixtures extends Fixture
], ],
], ],
[ [
'name' => 'TURPAULT Muriel', 'name' => 'TURPAULT MURIEL',
'email' => null, 'email' => null,
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'TURPAULT Muriel',
'street' => '23Bis Rue Marcel Pagnol', 'street' => '23Bis Rue Marcel Pagnol',
'street2' => null, 'street2' => null,
'postalCode' => '86100', 'postalCode' => '86100',
@@ -311,6 +298,34 @@ class ReferenceFixtures extends Fixture
], ],
], ],
], ],
[
'name' => 'EARL DE LA MENAUDIERE',
'email' => 'frederic.doussineau@orange.fr',
'phone' => '0675446004',
'addresses' => [
[
'street' => '1 la menaudière',
'street2' => null,
'postalCode' => '86450',
'city' => 'LEIGNE LES BOIS ',
'countryCode' => 'FR',
],
],
],
[
'name' => 'SARL ERBS',
'email' => 'touillet.jacques@yahoo.fr',
'phone' => '0675030304',
'addresses' => [
[
'street' => 'les rodières ',
'street2' => null,
'postalCode' => '86230',
'city' => 'Sérigny',
'countryCode' => 'FR',
],
],
],
]; ];
foreach ($suppliers as $supplierData) { foreach ($suppliers as $supplierData) {
@@ -321,10 +336,9 @@ class ReferenceFixtures extends Fixture
; ;
foreach ($supplierData['addresses'] as $addressData) { foreach ($supplierData['addresses'] as $addressData) {
$addressKey = sprintf('%s|%s', $addressData['label'], $addressData['postalCode']); $addressKey = sprintf('%s|%s', $addressData['street'], $addressData['postalCode']);
if (!isset($addressIndex[$addressKey])) { if (!isset($addressIndex[$addressKey])) {
$addressIndex[$addressKey] = new Address() $addressIndex[$addressKey] = new Address()
->setLabel($addressData['label'])
->setStreet($addressData['street']) ->setStreet($addressData['street'])
->setStreet2($addressData['street2']) ->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode']) ->setPostalCode($addressData['postalCode'])
@@ -347,7 +361,6 @@ class ReferenceFixtures extends Fixture
'email' => 'eurl.arnault86@orange.fr', 'email' => 'eurl.arnault86@orange.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -362,7 +375,6 @@ class ReferenceFixtures extends Fixture
'email' => 'sandra.robineaux@covilim.com', 'email' => 'sandra.robineaux@covilim.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'COVILIM',
'street' => 'Rue de Nexon', 'street' => 'Rue de Nexon',
'street2' => null, 'street2' => null,
'postalCode' => '87000', 'postalCode' => '87000',
@@ -372,12 +384,11 @@ class ReferenceFixtures extends Fixture
], ],
], ],
[ [
'name' => 'Les producteurs de la marche (LPM)', 'name' => 'LES PRODUCTEURS DE LA MARCHE (LPM)',
'phone' => '05.55.63.04.53', 'phone' => '05.55.63.04.53',
'email' => 'f.legalliard@lpmcoop.fr', 'email' => 'f.legalliard@lpmcoop.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'Les producteurs de la marche (LPM)',
'street' => 'Malonze', 'street' => 'Malonze',
'street2' => null, 'street2' => null,
'postalCode' => '23300', 'postalCode' => '23300',
@@ -392,7 +403,6 @@ class ReferenceFixtures extends Fixture
'email' => 'contact86@lortholarybetail.com', 'email' => 'contact86@lortholarybetail.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY BETAIL',
'street' => 'FERME DE GENIEC', 'street' => 'FERME DE GENIEC',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -407,7 +417,6 @@ class ReferenceFixtures extends Fixture
'email' => 'scouillaud@terrena.fr', 'email' => 'scouillaud@terrena.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'LA NOELLE', 'street' => 'LA NOELLE',
'street2' => 'BP 20199', 'street2' => 'BP 20199',
'postalCode' => '44155', 'postalCode' => '44155',
@@ -426,10 +435,9 @@ class ReferenceFixtures extends Fixture
; ;
foreach ($customerData['addresses'] as $addressData) { foreach ($customerData['addresses'] as $addressData) {
$addressKey = sprintf('%s|%s', $addressData['label'], $addressData['postalCode']); $addressKey = sprintf('%s|%s', $addressData['street'], $addressData['postalCode']);
if (!isset($addressIndex[$addressKey])) { if (!isset($addressIndex[$addressKey])) {
$addressIndex[$addressKey] = new Address() $addressIndex[$addressKey] = new Address()
->setLabel($addressData['label'])
->setStreet($addressData['street']) ->setStreet($addressData['street'])
->setStreet2($addressData['street2']) ->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode']) ->setPostalCode($addressData['postalCode'])

View File

@@ -46,9 +46,9 @@ class Address
#[Groups(['address:read', 'supplier:read', 'customer:read', 'shipment:read'])] #[Groups(['address:read', 'supplier:read', 'customer:read', 'shipment:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 120)] #[ORM\Column(length: 120, nullable: true)]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])] #[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read'])]
private string $label = ''; private ?string $label = null;
#[ORM\Column(length: 180)] #[ORM\Column(length: 180)]
#[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])] #[Groups(['address:read', 'supplier:read', 'reception:read', 'customer:read', 'shipment:read', 'address:write'])]
@@ -93,12 +93,12 @@ class Address
return $this->id; return $this->id;
} }
public function getLabel(): string public function getLabel(): ?string
{ {
return $this->label; return $this->label;
} }
public function setLabel(string $label): self public function setLabel(?string $label): self
{ {
$this->label = $label; $this->label = $label;

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\State\BovineProcessor;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Context;
@@ -31,12 +32,14 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['bovine:read']], normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']], denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
), ),
new Patch( new Patch(
requirements: ['id' => '\d+'], requirements: ['id' => '\d+'],
normalizationContext: ['groups' => ['bovine:read']], normalizationContext: ['groups' => ['bovine:read']],
denormalizationContext: ['groups' => ['bovine:write']], denormalizationContext: ['groups' => ['bovine:write']],
security: "is_granted('ROLE_ADMIN')", security: "is_granted('ROLE_ADMIN')",
processor: BovineProcessor::class,
), ),
], ],
security: "is_granted('ROLE_USER')", security: "is_granted('ROLE_USER')",
@@ -46,19 +49,19 @@ class Bovine
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column] #[ORM\Column]
#[Groups(['bovine:read'])] #[Groups(['bovine:read', 'building_case:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 50)] #[ORM\Column(length: 50)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private string $nationalNumber = ''; private string $nationalNumber = '';
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?int $receivedWeight = null; private ?int $receivedWeight = null;
#[ORM\Column(type: 'date_immutable', nullable: true)] #[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $arrivalDate = null; private ?DateTimeImmutable $arrivalDate = null;
@@ -66,6 +69,23 @@ class Bovine
#[Groups(['bovine:read', 'bovine:write'])] #[Groups(['bovine:read', 'bovine:write'])]
private ?BuildingCase $buildingCase = null; private ?BuildingCase $buildingCase = null;
#[ORM\ManyToOne]
#[Groups(['bovine:read', 'bovine:write', 'building_case:read'])]
private ?Supplier $supplier = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $workNumber = null;
#[ORM\Column(type: 'date_immutable', nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
private ?DateTimeImmutable $birthDate = null;
#[ORM\Column(length: 20, nullable: true)]
#[Groups(['bovine:read', 'building_case:read'])]
private ?string $breedCode = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -118,4 +138,52 @@ class Bovine
return $this; return $this;
} }
public function getSupplier(): ?Supplier
{
return $this->supplier;
}
public function setSupplier(?Supplier $supplier): static
{
$this->supplier = $supplier;
return $this;
}
public function getWorkNumber(): ?string
{
return $this->workNumber;
}
public function setWorkNumber(?string $workNumber): static
{
$this->workNumber = $workNumber;
return $this;
}
public function getBirthDate(): ?DateTimeImmutable
{
return $this->birthDate;
}
public function setBirthDate(?DateTimeImmutable $birthDate): static
{
$this->birthDate = $birthDate;
return $this;
}
public function getBreedCode(): ?string
{
return $this->breedCode;
}
public function setBreedCode(?string $breedCode): static
{
$this->breedCode = $breedCode;
return $this;
}
} }

View File

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

View File

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

View File

@@ -67,7 +67,7 @@ class Carrier
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = mb_strtoupper($name);
return $this; return $this;
} }

View File

@@ -59,6 +59,11 @@ class Customer
#[Groups(['customer:read', 'customer:write', 'shipment:read'])] #[Groups(['customer:read', 'customer:write', 'shipment:read'])]
private ?string $phone = null; private ?string $phone = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'created_by', nullable: true)]
#[Groups(['customer:read', 'customer:write'])]
private ?User $createdBy = null;
/** /**
* @var Collection<int, Address> * @var Collection<int, Address>
*/ */
@@ -85,7 +90,7 @@ class Customer
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = mb_strtoupper($name);
return $this; return $this;
} }
@@ -114,6 +119,18 @@ class Customer
return $this; return $this;
} }
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(?User $createdBy): self
{
$this->createdBy = $createdBy;
return $this;
}
public function getAddresses(): Collection public function getAddresses(): Collection
{ {
return $this->addresses; return $this->addresses;

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
@@ -30,6 +31,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Table(name: 'reception')] #[ORM\Table(name: 'reception')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])] #[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiResource( #[ApiResource(
order: ['id' => 'DESC'],
operations: [ operations: [
new Get( new Get(
requirements: ['id' => '\d+'], requirements: ['id' => '\d+'],
@@ -47,6 +49,9 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['reception:read']], normalizationContext: ['groups' => ['reception:read']],
denormalizationContext: ['groups' => ['reception:write']], denormalizationContext: ['groups' => ['reception:write']],
), ),
new Delete(
requirements: ['id' => '\d+'],
),
new Get( new Get(
uriTemplate: '/receptions/weigh', uriTemplate: '/receptions/weigh',
openapi: new OpenApiOperation( openapi: new OpenApiOperation(
@@ -96,7 +101,10 @@ class Reception
#[ORM\Column(name: 'date_reception', type: 'datetime_immutable')] #[ORM\Column(name: 'date_reception', type: 'datetime_immutable')]
#[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])] #[Groups(['reception:read', 'reception:write', 'reception-bovine:read'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context(
normalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i'],
denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'],
)]
private ?DateTimeImmutable $receptionDate = null; private ?DateTimeImmutable $receptionDate = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
@@ -259,6 +267,14 @@ class Reception
public function setReceptionDate(?DateTimeImmutable $receptionDate): self public function setReceptionDate(?DateTimeImmutable $receptionDate): self
{ {
if (null !== $receptionDate && null !== $this->receptionDate) {
// Préserve l'heure existante quand seule la date est mise à jour
$receptionDate = $receptionDate->setTime(
(int) $this->receptionDate->format('H'),
(int) $this->receptionDate->format('i'),
(int) $this->receptionDate->format('s'),
);
}
$this->receptionDate = $receptionDate; $this->receptionDate = $receptionDate;
return $this; return $this;
@@ -457,8 +473,16 @@ class Reception
#[ORM\PrePersist] #[ORM\PrePersist]
public function initializeReceptionDate(): void public function initializeReceptionDate(): void
{ {
$now = new DateTimeImmutable();
if (null === $this->receptionDate) { if (null === $this->receptionDate) {
$this->receptionDate = new DateTimeImmutable(); $this->receptionDate = $now;
} else {
// Préserve la date choisie mais utilise l'heure courante
$this->receptionDate = $this->receptionDate->setTime(
(int) $now->format('H'),
(int) $now->format('i'),
(int) $now->format('s'),
);
} }
} }

Some files were not shown because too many files have changed in this diff Show More