Compare commits

...

41 Commits

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

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

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

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #41
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 14:53:43 +00:00
gitea-actions
fa7b44fb02 chore: bump version to v0.0.78
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 12m45s
2026-03-25 14:16:03 +00:00
9be2e0c379 [#FER-11] Corriger le problème de bearer token (!40)
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #40
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 14:15:54 +00:00
gitea-actions
fee7bbb2ec chore: bump version to v0.0.77
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build Release Artefact / build (push) Successful in 1m47s
2026-03-24 07:33:27 +00:00
b707aae0e8 fix : bouton de mise en attente
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-24 08:33:13 +01:00
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
gitea-actions
d0213c3212 chore: bump version to v0.0.67
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m15s
2026-02-27 13:31:51 +00:00
3ac676689d [#354] modification front page admin utilisateur (!39)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        #354          |       modification front page admin utilisateur          |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #39
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-27 13:31:45 +00:00
gitea-actions
9f47e81efd chore: bump version to v0.0.66
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m21s
2026-02-27 13:09:26 +00:00
257b93e691 [#353] front de la page admin client (!38)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|        [#353]          |     front de la page admin client     |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #38
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-27 13:09:19 +00:00
gitea-actions
dc5320b324 chore: bump version to v0.0.65
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m17s
2026-02-27 13:05:53 +00:00
09a641e5cf [#356] modification front page admin bovin (!37)
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|   #356              |      modification front page admin bovin           |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #37
Reviewed-by: Autin <tristan@yuno.malio.fr>
Co-authored-by: sroy <sebastien@yuno.malio.fr>
Co-committed-by: sroy <sebastien@yuno.malio.fr>
2026-02-27 13:05:47 +00:00
138 changed files with 4771 additions and 2719 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

@@ -55,7 +55,17 @@ Ajouter dans le fichier .env du frontend
* [#332] Refonte écran réception terminée * [#332] Refonte écran réception terminée
* [#327] afficher/modifier écran expédition terminée * [#327] afficher/modifier écran expédition terminée
* [#352] modification front admin fournisseur * [#352] modification front admin fournisseur
* [#355] afficher/modifier écran expédition terminée * [#355] modification front admin transporteur
* [#356] front page admin bovin
* [#353] modification front admin client
* [#353] modification front admin utilisateur
* [#FER-11] Corriger le problème de bearer token
* [#FER-12] Ajouter un blocage des utilisateurs
* [#FER-13] Faire des recherches sur le scanner des bêtes
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#FER-17] Ecran d'ajout de bovin
* [#FER-18] Mise à jour du tableau d'arrivage
### Changed ### 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

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

View File

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

176
composer.lock generated
View File

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

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.0.64' app.version: '0.0.85'

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": {
@@ -82,7 +84,13 @@
"list": "Impossible de récupérer la liste des camions." "list": "Impossible de récupérer la liste des camions."
}, },
"bovin": { "bovin": {
"list": "Impossible de récupérer la liste des races de bovins." "list": "Impossible de récupérer la liste des races de bovins.",
"fetch": "Impossible de récupérer le type bovin.",
"create": "Impossible de créer 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.",
@@ -106,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.",
@@ -133,6 +145,13 @@
"update": "Transporteur mis à jour", "update": "Transporteur mis à jour",
"create": "Transporteur créé" "create": "Transporteur créé"
}, },
"bovin": {
"update": "Type bovin mis à jour 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"
@@ -108,7 +91,24 @@
<NuxtLink <NuxtLink
v-if="auth.isAdmin" v-if="auth.isAdmin"
to="/admin/bovin/list" to="/admin/carrier/carrier-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/carrier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Transporteurs
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/bovin/bovin-list"
custom custom
v-slot="{ href, navigate }" v-slot="{ href, navigate }"
> >
@@ -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"/>
@@ -214,9 +232,12 @@
<NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu"> <NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu">
Clients Clients
</NuxtLink> </NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/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,24 +1,33 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="text-primary-500 flex items-center justify-between"> <div class="flex items-center justify-between relative">
<h1 class="text-3xl font-bold uppercase"> <div class="flex flex-row absolute -left-[60px]">
{{ route.params.id ? 'Modifier bovin' : 'Ajout bovin' }} <Icon
@click="router.push('/admin/bovin/bovin-list')"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ route.params.id ? 'Modifications du type bovin' : 'Ajout d\'un type bovin' }}
</h1> </h1>
</div>
<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" required />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" required />
</div>
<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 text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2" class="inline-flex items-center justify-center text-xl min-w-[194px] 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" /> Valider
{{ isEdit ? 'Valider' : 'Ajouter' }}
</UiButton> </UiButton>
</div> </div>
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12">
<UiTextInput label="Nom du bovin" id="bovin-label" v-model="form.label" />
<UiTextInput label="Code bovin" id="code-id" v-model="form.code" />
</div>
</form> </form>
</template> </template>
@@ -29,6 +38,9 @@ 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 isEdit = computed(() => idBovin.value !== null)
function resolveId(param: unknown) { function resolveId(param: unknown) {
const idStr = Array.isArray(param) ? param[0] : param const idStr = Array.isArray(param) ? param[0] : param
@@ -37,9 +49,6 @@ function resolveId(param: unknown) {
return Number.isFinite(id) ? id : null return Number.isFinite(id) ? id : null
} }
const idBovin = computed(() => resolveId(route.params.id))
const isEdit = computed(() => idBovin.value !== null)
const form = reactive<BovinFormData>({ const form = reactive<BovinFormData>({
label: '', label: '',
code: '' code: ''
@@ -92,7 +101,6 @@ async function validate() {
} else { } else {
await createBovin(basePayload) await createBovin(basePayload)
} }
await navigate()
} finally { } finally {
isLoading.value = false isLoading.value = false
} }

View File

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

View File

@@ -1,72 +0,0 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-primary-500 uppercase">Liste des types bovins</h1>
<NuxtLink
to="/admin/bovin"
class="inline-flex items-center justify-center
text-xl text-white uppercase
bg-primary-500 h-[50px] px-8 rounded
hover:opacity-80 gap-2"
@click="handleAddClick"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16">
<div class="max-h-96 overflow-y-auto">
<div
class="sticky
grid grid-cols-2 gap-4
bg-slate-100 px-4 py-3
font-semibold uppercase
tracking-wide"
>
<div class="col-span-1">Nom</div>
<div class="col-span-1">Code</div>
</div>
<div v-if="bovinList.length === 0" class="px-4 py-6 text-slate-400">
Aucun type de bovin.
</div>
<div v-else>
<div
v-for="bovin in bovinList"
:key="bovin.id"
class="grid grid-cols-2 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
@click="goToBovin(bovin.id)"
>
<div class="col-span-1">{{ bovin.label }}</div>
<div class="col-span-1">{{ bovin.code }}</div>
</div>
</div>
</div>
</div>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
</template>
<script setup lang="ts">
import { getBovineTypeList } from "~/services/bovine-type"
import type { BovineTypeData } from "~/services/dto/bovine-type-data"
import { useAuthStore } from "~/stores/auth"
const bovinList = ref<BovineTypeData[]>([])
const router = useRouter()
const auth = useAuthStore()
const goToBovin = (id: number) => {
if (!auth.isAdmin) return
router.push(`/admin/bovin/${id}`)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
}
onMounted(async () => {
if (!auth.isAdmin) return
bovinList.value = await getBovineTypeList()
})
</script>

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
@@ -10,8 +10,8 @@
class="cursor-pointer text-primary-500" class="cursor-pointer text-primary-500"
/> />
</div> </div>
<h1 class="text-4xl text-primary-500 font-bold uppercase"> <h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ route.params.id ? 'Modifications du transporteur' : 'Ajout d\'un transporteur' }} {{ route.params.id ? 'Modification du transporteur' : 'Ajout d\'un transporteur' }}
</h1> </h1>
</div> </div>
@@ -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
@@ -101,11 +105,10 @@ async function validate() {
if(idCarrier.value){ if(idCarrier.value){
await updateCarrier(idCarrier.value, basePayload) await updateCarrier(idCarrier.value, basePayload)
navigate()
return return
}else{
await createCarrier(basePayload)
} }
await createCarrier(basePayload)
navigate()
} }
function navigate(){ function navigate(){

View File

@@ -1,55 +1,63 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between"> <div class="flex items-center relative">
<h1 class="text-3xl font-bold uppercase"> <div class="flex flex-row absolute -left-[60px] ">
{{ customerId ? "Modifications du client" : "Ajout d'un client" }} <Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ customerId ? "Modification du client" : "Ajout d'un client" }}
</h1> </h1>
</div>
<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]" required/>
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin" wrapper-class="w-[280px]" required/>
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin" wrapper-class="w-[280px]"/>
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!customerId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton <UiButton
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2" class="inline-flex 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>
<div class="grid grid-cols-2 gap-y-8 gap-x-80 mb-10 py-12"> <template v-if="customerId">
<UiTextInput id="customer-name" v-model="form.name" label="Nom du client" :disabled="!auth.isAdmin"/> <div class="flex items-center justify-between mb-7">
<UiTextInput id="customer-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin"/> <h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2>
<UiTextInput id="customer-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin"/>
</div> </div>
<div class="overflow-x-auto mb-11 text-primary-700">
<div class="mx-24 mb-4 py-6 border-t border-black"></div> <table class="w-full border-collapse text-primary-700">
<div class="flex items-center justify-between mb-4">
<h2 class="text-3xl font-bold uppercase">Adresses client</h2>
<UiButton
type="button"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
:disabled="customerId === null || !auth.isAdmin"
@click="goToAddAddress"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</UiButton>
</div>
<div class="overflow-x-auto mb-10">
<table class="w-full border-collapse">
<thead> <thead>
<tr class="text-left border-b border-gray-200"> <tr class="text-left border bg-slate-100 border-gray-200">
<th class="py-3 pr-4 text-sm uppercase">Libellé</th> <th class="py-3 px-4 text-sm uppercase">Rue</th>
<th class="py-3 pr-4 text-sm uppercase">Rue</th> <th class="py-3 px-4 text-sm uppercase">Complément</th>
<th class="py-3 pr-4 text-sm uppercase">Complément</th> <th class="py-3 px-4 text-sm uppercase">Code postal</th>
<th class="py-3 pr-4 text-sm uppercase">Code postal</th> <th class="py-3 px-4 text-sm uppercase">Ville</th>
<th class="py-3 pr-4 text-sm uppercase">Ville</th> <th class="py-3 px-4 text-sm uppercase">Pays</th>
<th class="py-3 pr-4 text-sm uppercase">Pays</th>
</tr> </tr>
</thead> </thead>
<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>
@@ -58,21 +66,32 @@
<tr <tr
v-for="(address, index) in form.addresses" v-for="(address, index) in form.addresses"
:key="address.id ?? index" :key="address.id ?? index"
class="border-b border-gray-100 hover:bg-slate-50" class="border border-gray-100 hover:bg-slate-50"
: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 pr-4">{{ address.label || "—" }}</td> <td class="py-3 px-4">{{ address.street || "—" }}</td>
<td class="py-3 pr-4">{{ address.street || "—" }}</td> <td class="py-3 px-4">{{ address.street2 || "—" }}</td>
<td class="py-3 pr-4">{{ address.street2 || "—" }}</td> <td class="py-3 px-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 pr-4">{{ address.postalCode || "—" }}</td> <td class="py-3 px-4">{{ address.city || "—" }}</td>
<td class="py-3 pr-4">{{ address.city || "—" }}</td> <td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
<td class="py-3 pr-4">{{ address.countryCode || "—" }}</td>
</tr> </tr>
</template> </template>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="flex justify-center items-center">
<UiButton
type="button"
class="inline-flex items-center justify-center text-xl gap-2 text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
:disabled="customerId === null || !auth.isAdmin"
@click="goToAddAddress"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</UiButton>
</div>
</template>
</form> </form>
</template> </template>
@@ -80,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()
@@ -94,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: "",
@@ -101,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({
@@ -138,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 ?? "",
@@ -183,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

@@ -16,13 +16,10 @@ const addressId = computed(() => (route.query.addressId !== undefined ? Number(r
const address = ref<AddressData | null>(null) const address = ref<AddressData | null>(null)
const validate = async (payload: AddressPayload) => { const validate = async (payload: AddressPayload) => {
try { if (addressId.value !== null) {
if (addressId.value !== null) { await updateAddress(addressId.value, payload)
await updateAddress(addressId.value, payload) } else {
} else { await addAddress(payload)
await addAddress(payload)
}
} finally {
await router.push("/admin/customer/" + customerId.value) await router.push("/admin/customer/" + customerId.value)
} }
} }

View File

@@ -1,92 +1,50 @@
<template> <template>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des Clients</h1> <h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
<NuxtLink
to="/admin/customer"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
@click="handleAddClick"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div> </div>
<div v-if="auth.isAdmin" class="mt-6 border border-slate-200 mb-16"> <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 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 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 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 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>
<div v-else class="mt-6 border border-slate-200 mb-16 px-4 py-6 text-slate-400"> <div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs. Accès réservé aux administrateurs.
</div> </div>
<div class="flex justify-center items-center">
<NuxtLink
to="/admin/customer"
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
@click="handleAddClick"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View File

@@ -1,39 +1,53 @@
<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/supplier/supplier-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/> <Icon @click="router.push('/admin/supplier/supplier-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
</div> </div>
<h1 class="text-3xl text-primary-500 font-bold uppercase"> <h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ supplierId ? "Modifications du fournisseur" : "Ajout d'un fournisseur" }} {{ supplierId ? "Modification du fournisseur" : "Ajout d'un fournisseur" }}
</h1> </h1>
</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 fournisseur</h2> <h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2>
</div> </div>
<div class="overflow-x-auto mb-11 text-primary-700"> <div class="overflow-x-auto mb-11 text-primary-700">
<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

@@ -16,15 +16,12 @@ const addressId = computed(() => { return route.query.addressId !== undefined ?
const address = ref<AddressData|null>(null) const address = ref<AddressData|null>(null)
const validate = async (address: AddressPayload) => { const validate = async (address: AddressPayload) => {
try {
if (addressId.value !== null) { if (addressId.value !== null) {
await updateAddress(addressId.value, address) await updateAddress(addressId.value, address)
} else { } else {
await addAddress(address) await addAddress(address)
await router.push('/admin/supplier/' + supplierId.value)
} }
} finally {
await router.push('/admin/supplier/' + supplierId.value)
}
} }
const addAddress = async (address: AddressPayload) => { const addAddress = async (address: AddressPayload) => {

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,54 +1,92 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div <div class="flex items-center relative">
class="flex items-center justify-between gap-10"> <div class="flex flex-row absolute -left-[60px]">
<h1 class="text-3xl font-bold uppercase"> <Icon
{{ userId ? "Modifications de l'utilisateur" : "Ajout d'un utilisateur" }} @click="router.push('/admin/user/list')"
name="gg:arrow-left-o"
size="40"
class="cursor-pointer text-primary-500"
/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ userId ? "Modification de l'utilisateur" : "Ajout d'un utilisateur" }}
</h1> </h1>
<UiButton
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
type="submit"
>
<Icon :name="userId ? '' : 'mdi:plus'" size="28" />
{{ userId ? 'Valider' : 'Ajouter' }}
</UiButton>
</div> </div>
<div class="grid gap-y-16 gap-x-40 py-12"> <div class="flex flex-cols-3 justify-between mb-11 pt-7">
<UiTextInput <UiTextInput
id="user-name" id="user-name"
v-model="form.username" v-model="form.username"
label="Nom de l'utilisateur" label="Nom de l'utilisateur"
:disabled="!auth.isAdmin"
wrapper-class="w-[280px]"
required
/> />
<UiSelect <UiSelect
id="user-role" id="user-role"
v-model="form.role" v-model="form.role"
label="Rôle de l'utilisateur" label="Role de l'utilisateur"
:options="ROLE" :options="ROLE"
:disabled="!auth.isAdmin"
wrapper-class="w-[280px]"
required
/> />
<UiTextInput <UiTextInput
id="user-password" id="user-password"
v-model="form.password" v-model="form.password"
label="Mot de passe" label="Mot de passe"
type="password" type="password"
:disabled="!auth.isAdmin"
wrapper-class="w-[280px]"
:required="!userId"
/> />
</div>
<div class="flex items-center mb-11">
<label class="flex items-center gap-2 cursor-pointer">
<input
id="user-locked"
v-model="form.isLocked"
type="checkbox"
:disabled="!auth.isAdmin"
class="w-5 h-5 accent-primary-500"
/>
<span class="text-sm text-primary-700">Verrouiller le compte</span>
</label>
<p class="ml-4 text-xs text-slate-400">Un compte verrouillé ne peut plus se connecter.</p>
</div>
<div class="flex items-center justify-center">
<UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit"
:disabled="isLoading || isHydrating || !auth.isAdmin"
@click="submitted = true"
>
<Icon :name="userId ? '' : 'mdi:plus'" size="28" />
{{ userId ? 'Valider' : 'Ajouter' }}
</UiButton>
</div> </div>
</form> </form>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {computed, reactive, ref, watch} from 'vue' import { computed, reactive, ref, watch } from 'vue'
import {ROLE} from '~/utils/constants' import { ROLE } from '~/utils/constants'
import {createUser, updateUser, getUser} from '~/services/auth' import { createUser, updateUser, getUser } from '~/services/auth'
import type {UserData, UserFormData, UserPayload} from '~/services/dto/user-data' import type { UserData, UserFormData, UserPayload } from '~/services/dto/user-data'
import { useAuthStore } from '~/stores/auth'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
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
@@ -59,11 +97,11 @@ const resolveUserId = (param: unknown) => {
return Number.isFinite(id) ? id : null return Number.isFinite(id) ? id : null
} }
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) => {
@@ -73,9 +111,10 @@ const hydrateFromUser = (user: UserData | null) => {
isHydrating.value = true isHydrating.value = true
form.username = user.username ?? '' form.username = user.username ?? ''
const roles = user.roles ?? [] const roles = user.roles ?? []
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
} }
@@ -93,10 +132,11 @@ watch(
isLoading.value = false isLoading.value = false
} }
}, },
{immediate: true} { immediate: true }
) )
async function validate() { async function validate() {
if (!auth.isAdmin) return
const normalizedUsername = form.username.trim() const normalizedUsername = form.username.trim()
const normalizedRole = form.role.trim() const normalizedRole = form.role.trim()
@@ -105,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
@@ -112,13 +153,12 @@ async function validate() {
if (userId.value) { if (userId.value) {
await updateUser(userId.value, basePayload) await updateUser(userId.value, basePayload)
await router.push(`/admin/user/list/`)
return return
} }
const created = await createUser(basePayload) const created = await createUser(basePayload)
if (created) { if (created) {
await router.push(`/admin/user/list/`) await router.push('/admin/user/list')
} }
} }
</script> </script>

View File

@@ -1,58 +1,83 @@
<template> <template>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-3xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1> <h1 class="text-4xl font-bold uppercase text-primary-500">Liste des utilisateurs</h1>
<NuxtLink
to="/admin/user"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div> </div>
<div> <div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
<div class="mt-6 border border-slate-200 mb-16 "> <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 class="grid grid-cols-3 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> <div>Utilisateur</div>
<div>Username</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">
Aucun utilisateur.
</div>
<template v-else>
<div <div
v-for="user in userList" v-for="user in userList"
:key="user.id" :key="user.id"
class="grid grid-cols-3 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t 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)"
@keydown.enter="goToUser(user.id)"
> >
<div>{{ user.username }}</div>
<div>{{ getRoleLabels(user.roles) }}</div>
<div> <div>
{{ user.username }} <span
</div> v-if="user.isLocked"
<div> class="inline-block px-2 py-0.5 text-xs font-semibold rounded bg-red-100 text-red-700"
{{ getRoleLabels(user.roles) }} >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> </div>
</div> </template>
</div>
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
Acces reserve aux administrateurs.
</div>
<div class="flex justify-center items-center">
<NuxtLink
to="/admin/user"
class="inline-flex items-center mb-16 justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
@click="handleAddClick"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type {UserData} from "~/services/dto/user-data"; import type { UserData } from "~/services/dto/user-data"
import {getAdminUsers} from "~/services/auth"; import { getAdminUsers } from "~/services/auth"
import {ROLE} from "~/utils/constants"; import { ROLE } from "~/utils/constants"
import { useAuthStore } from "~/stores/auth"
const userList = ref<UserData[]>([]) const userList = ref<UserData[]>([])
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label])) const roleLabelByValue = new Map(ROLE.map((role) => [role.value, role.label]))
const goToUser = (id: number) => { const goToUser = (id: number) => {
if (!auth.isAdmin) return
router.push(`/admin/user/${id}`) router.push(`/admin/user/${id}`)
} }
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
}
const getRoleLabels = (roles?: string[]) => { const getRoleLabels = (roles?: string[]) => {
if (!roles || roles.length === 0) { if (!roles || roles.length === 0) {
return ' ---' return '---'
} }
return roles return roles
@@ -61,6 +86,7 @@ const getRoleLabels = (roles?: string[]) => {
} }
onMounted(async () => { onMounted(async () => {
if (!auth.isAdmin) return
userList.value = await getAdminUsers() userList.value = await getAdminUsers()
}) })
</script> </script>

View File

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

View File

@@ -36,7 +36,7 @@
v-for="cell in entry.cells" 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

@@ -24,19 +24,27 @@ export async function getBovineTypeList(): Promise<BovineTypeData[]> {
export async function getBovin(id: number): Promise<BovineTypeData> { export async function getBovin(id: number): Promise<BovineTypeData> {
const api = useApi() const api = useApi()
const response = await api.get<BovineTypeData>(`bovine_types/${id}`) const response = await api.get<BovineTypeData>(`bovine_types/${id}`, {}, {
toastErrorKey: 'errors.bovin.fetch'
})
return mapToBovineTypeData(response) return mapToBovineTypeData(response)
} }
export async function createBovin(payload: BovinPayload = {}): Promise<BovineTypeData> { export async function createBovin(payload: BovinPayload = {}): Promise<BovineTypeData> {
const api = useApi() const api = useApi()
const response = await api.post<BovineTypeData>('bovine_types', toBovineTypePayload(payload)) const response = await api.post<BovineTypeData>('bovine_types', toBovineTypePayload(payload), {
toastErrorKey: 'errors.bovin.create',
toastSuccessKey: 'success.bovin.create'
})
return mapToBovineTypeData(response) return mapToBovineTypeData(response)
} }
export async function updateBovin(id: number, payload: BovinPayload = {}): Promise<BovineTypeData> { export async function updateBovin(id: number, payload: BovinPayload = {}): Promise<BovineTypeData> {
const api = useApi() const api = useApi()
const response = await api.patch<BovineTypeData>(`bovine_types/${id}`, toBovineTypePayload(payload)) const response = await api.patch<BovineTypeData>(`bovine_types/${id}`, toBovineTypePayload(payload), {
toastErrorKey: 'errors.bovin.update',
toastSuccessKey: 'success.bovin.update'
})
return mapToBovineTypeData(response) return mapToBovineTypeData(response)
} }

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

View File

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

View File

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

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