Compare commits

...

25 Commits

Author SHA1 Message Date
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
gitea-actions
a0557b077b chore: bump version to v0.0.64
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m20s
2026-02-27 10:06:55 +00:00
2d2b38eae4 [#355] modification front de la page admin transporteur (!36)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|       #355           |      modification front de la page admin transporteur           |

## 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: #36
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 10:06:49 +00:00
83 changed files with 2725 additions and 2121 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>

232
.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 : order navbar + modification création fournisseur et client">
<change beforePath="$PROJECT_DIR$/.claude/settings.local.json" beforeDir="false" afterPath="$PROJECT_DIR$/.claude/settings.local.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" 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$/src/Entity/Reception.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Reception.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/Shipment.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Shipment.php" 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="feat/327-voir-modifier-une-expedition-terminee" />
</map> </map>
</option> </option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" /> <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
@@ -223,59 +225,60 @@
<option name="hideEmptyMiddlePackages" value="true" /> <option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" /> <option name="showLibraryContents" value="true" />
</component> </component>
<component name="PropertiesComponent"><![CDATA[{ <component name="PropertiesComponent">{
"keyToString": { &quot;keyToString&quot;: {
"RunOnceActivity.MCP Project settings loaded": "true", &quot;RunOnceActivity.MCP Project settings loaded&quot;: &quot;true&quot;,
"RunOnceActivity.ShowReadmeOnStart": "true", &quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true", &quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
"RunOnceActivity.git.unshallow": "true", &quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
"RunOnceActivity.typescript.service.memoryLimit.init": "true", &quot;RunOnceActivity.typescript.service.memoryLimit.init&quot;: &quot;true&quot;,
"git-widget-placeholder": "feat/352-modification-front-admin-fournisseur", &quot;git-widget-placeholder&quot;: &quot;develop&quot;,
"last_opened_file_path": "/home/sroy/Documents/test/Ferme/frontend/components/commun", &quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme&quot;,
"node.js.detected.package.eslint": "true", &quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
"node.js.detected.package.tslint": "true", &quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
"node.js.selected.package.eslint": "(autodetect)", &quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
"node.js.selected.package.tslint": "(autodetect)", &quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
"nodejs_package_manager_path": "npm", &quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
"settings.editor.selected.configurable": "preferences.pluginManager", &quot;settings.editor.selected.configurable&quot;: &quot;advanced.settings&quot;,
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external", &quot;ts.external.directory.path&quot;: &quot;/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external&quot;,
"vue.rearranger.settings.migration": "true" &quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}, },
"keyToStringList": { &quot;keyToStringList&quot;: {
"DatabaseDriversLRU": [ &quot;DatabaseDriversLRU&quot;: [
"postgresql" &quot;postgresql&quot;
], ],
"com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File": [ &quot;com.intellij.ide.scratch.ScratchImplUtil$2/New Scratch File&quot;: [
"TEXT" &quot;TEXT&quot;
], ],
"vue.recent.templates": [ &quot;vue.recent.templates&quot;: [
"Vue Composition API Component" &quot;Vue Composition API Component&quot;
] ]
} }
}]]></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,70 +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="2660000" />
<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>
<task id="LOCAL-00028" summary="fix : doc et script de déploiement"> <task id="LOCAL-00028" summary="fix : doc et script de déploiement">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -703,7 +656,71 @@
<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>
<option name="localTasksCounter" value="77" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -753,14 +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 : 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 : creer une nouvelle expedtion (WIP)" />
<MESSAGE value="feat : ajout d'une page de creation d'une expedition" /> <MESSAGE value="feat : ajout d'une page de creation d'une expedition" />
@@ -778,7 +787,15 @@
<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" />
<option name="LAST_COMMIT_MESSAGE" value="fix : order navbar + modification création fournisseur et client" />
</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,10 @@ 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] modification front admin transporteur
* [#356] front page admin bovin
* [#353] modification front admin client
* [#353] modification front admin utilisateur
### Changed ### Changed
### Fixed ### Fixed

160
CLAUDE.md Normal file
View File

@@ -0,0 +1,160 @@
# 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`.
### 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,2 +1,2 @@
parameters: parameters:
app.version: '0.0.63' app.version: '0.0.75'

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,29 +10,36 @@
</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">
<button <UiButton
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2" class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
type="submit" type="submit"
:disabled="isLoading" :disabled="isLoading"
@click="submitted = true"
> >
<Icon :name="props.address ? '' : 'mdi:plus'" size="28" /> Valider
{{ props.address? "Valider" : "Ajouter" }} </UiButton>
</button>
</div> </div>
</form> </form>
</template> </template>
<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()
@@ -43,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())
@@ -71,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(
@@ -87,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 :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,38 @@
: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 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 +154,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 +182,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,185 +191,32 @@ 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)
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() { async function validate() {
const normalizedLicensePlate = form.licensePlate.trim() const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim() const normalizedReceptionDate = form.receptionDate.trim()
@@ -467,27 +227,14 @@ 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}` const receptionTypeIri = normalizedReceptionTypeId ? `/api/reception_types/${normalizedReceptionTypeId}` : null
: null const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
const userIri = normalizedUserId const supplierIri = normalizedSupplierId ? `/api/suppliers/${normalizedSupplierId}` : null
? `/api/users/${normalizedUserId}` const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
: null const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const supplierIri = normalizedSupplierId const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
? `/api/suppliers/${normalizedSupplierId}` const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
: 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 basePayload = {
licensePlate: normalizedLicensePlate, licensePlate: normalizedLicensePlate,
@@ -502,7 +249,7 @@ async function validate() {
const payload = { const payload = {
...basePayload, ...basePayload,
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {}) ...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {})
} }
if (!receptionStore.current) { if (!receptionStore.current) {
@@ -532,5 +279,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 :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,35 @@
: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 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 +164,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 +190,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 +244,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 +280,14 @@ 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}) defineExpose({ saveDraft })
// Valide le formulaire et crée/met à jour l'expédition
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,78 @@
<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,
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
})
</script>

View File

@@ -0,0 +1,77 @@
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
} = useWeighing({
mode: options.mode,
entity: options.entity,
entityName: options.entityName,
apiResource: options.apiResource,
titleLabel: options.titleLabel,
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,
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,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,64 @@
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
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,
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 +69,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,
@@ -71,15 +78,15 @@ export const useWeighing = ({
} }
const nextStep = mode === 'tare' const nextStep = mode === 'tare'
? 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)
} }
} }
@@ -95,86 +102,29 @@ export const useWeighing = ({
} }
} }
// 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 à vide' : 'Pesée à plein',
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,83 @@
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> = {}
datePayload[config.dateField] = store.current[config.dateField]
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: 'gross' },
{ label: 'Chargement' },
{ label: 'Pesée à plein', weighingMode: 'tare', isFinal: true }
],
weighingLabels: {
gross: 'Pesée à vide',
tare: 'Pesée à plein'
},
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,10 @@
"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."
}, },
"carrier": { "carrier": {
"list": "Impossible de récupérer la liste des transporteurs.", "list": "Impossible de récupérer la liste des transporteurs.",
@@ -106,10 +111,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 +142,10 @@
"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."
},
"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 }"
> >
@@ -134,7 +134,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,7 +215,7 @@
<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>
</nav> </nav>

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,32 +1,44 @@
<template> <template>
<form @submit.prevent="validate"> <form :class="{ submitted }" @submit.prevent="validate">
<div class="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 transporteur' : 'Ajout transporteur' }} <Icon
</h1> @click="router.push('/admin/carrier/carrier-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 ? 'Modification du transporteur' : 'Ajout d\'un transporteur' }}
</h1>
</div>
<UiButton <div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
type="submit" <UiTextInput
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 justify-self-end" label="Nom du transporteur"
> id="carrier-name"
Valider v-model="form.name"
</UiButton> required
</div> />
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 py-12"> <UiTextInput
<UiTextInput label="Code transporteur"
label = "nom du fournisseur"
id="carrier-name"
v-model="form.name"
/>
<UiTextInput
label = "code fournisseur"
id="code-id" id="code-id"
v-model="form.code" v-model="form.code"
/> required
</div> />
</div>
<div class="flex justify-center items-center">
<UiButton
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"
@click="submitted = true"
>
Valider
</UiButton>
</div>
</form> </form>
</template> </template>
@@ -40,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
@@ -92,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,25 +1,18 @@
<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">listes des transporteurs</h1> <h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
<NuxtLink
to="/admin/carrier"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div> </div>
<div class="mt-6 border border-slate-200 mb-16 "> <div class="mt-7 border border-slate-200 mb-11 ">
<div class="grid grid-cols-2 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"> <div class="grid grid-cols-2 gap-4 text-primary-700 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Label</div> <div>Label</div>
<div>Code</div> <div>Code</div>
</div> </div>
<div <div
v-for="carrier in carrierList" v-for="carrier in carrierList"
:key="carrier.id" :key="carrier.id"
class="grid grid-cols-2 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200" 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" role="button"
tabindex="0" tabindex="0"
@click="goToCarrier(carrier.id)" @click="goToCarrier(carrier.id)"
@@ -29,6 +22,15 @@
<div>{{ carrier.code }}</div> <div>{{ carrier.code }}</div>
</div> </div>
</div> </div>
<div class="flex justify-center items-center">
<NuxtLink
to="/admin/carrier"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
</template> </template>

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 items-center mb-[106px] 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="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>
@@ -71,7 +84,7 @@
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<UiButton <UiButton
type="button" type="button"
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="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="supplierId === null || !auth.isAdmin" :disabled="supplierId === null || !auth.isAdmin"
@click="goToAddAddress" @click="goToAddAddress"
> >
@@ -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,78 @@
<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
/> />
</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,7 +83,6 @@ 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: '',
@@ -73,8 +96,8 @@ 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 = ''
isHydrating.value = false isHydrating.value = false
} }
@@ -93,10 +116,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()
@@ -112,13 +136,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,72 @@
<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-2 text-primary-700 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div class="grid grid-cols-3 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>
</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-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200 items-center"
role="button" role="button"
tabindex="0" tabindex="0"
@click="goToUser(user.id)" @click="goToUser(user.id)"
@keydown.enter="goToUser(user.id)"
> >
<div> <div>{{ user.username }}</div>
{{ user.username }} <div>{{ getRoleLabels(user.roles) }}</div>
</div>
<div>
{{ getRoleLabels(user.roles) }}
</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 +75,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

@@ -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"
/> />
@@ -15,76 +15,70 @@
</UiButton> </UiButton>
</div> </div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/> <ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/> <WorkflowWeight
v-if="storeReception?.currentStep === 1"
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"
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 resolveReceptionId = (param: unknown) => { const { stepLabels, saveAndHold, 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
}
// Init route watcher
const route = useRoute()
watch( watch(
() => route.params.id, () => route.params.id,
async (param) => { async (param) => {
const id = resolveReceptionId(param) const idStr = Array.isArray(param) ? param[0] : param
if (id === null) { if (!idStr) {
receptionStore.clearCurrent() receptionStore.clearCurrent()
return return
} }
await receptionStore.loadReception(id) const id = Number(idStr)
if (Number.isFinite(id)) {
await receptionStore.loadReception(id)
}
}, },
{immediate: true} { immediate: true }
) )
const saveAndHold = async () => {
if (!receptionStore.current) {
await router.push('/')
return
}
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: receptionStore.current.currentStep,
licensePlate: receptionStore.current.licensePlate,
receptionDate: receptionStore.current.receptionDate
})
await router.push('/')
}
const handleStepSelect = async (step: number) => {
if (!receptionStore.current) {
return
}
if (step === receptionStore.current.currentStep) {
return
}
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: step
})
await receptionStore.loadReception(receptionStore.current.id)
}
</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,61 @@
<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
>
<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'
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 () => {

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,45 +16,69 @@
</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"
mode="gross"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.gross"
: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"
mode="tare"
entity-name="shipment"
api-resource="shipments"
:title-label="shipmentConfig.weighingLabels.tare"
: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> } | 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 () => {
@@ -64,20 +87,4 @@ const saveAndHold = async () => {
} }
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,14 +149,20 @@
<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
@@ -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,6 +239,15 @@ 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 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'>('weights') const activeTab = ref<'weightsEmpty' | 'weights'>('weights')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross')) const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare')) const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
@@ -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,85 @@
<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
>
<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 type {ShipmentData} from "~/services/dto/shipment-data"; const columns = [
import {getShipmentList} from "~/services/shipment"; { 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

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

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

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

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

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

@@ -216,6 +216,7 @@ class SeedCommand extends Command
['label' => 'Bâtiment 1', 'code' => 'B1'], ['label' => 'Bâtiment 1', 'code' => 'B1'],
['label' => 'Bâtiment 2', 'code' => 'B2'], ['label' => 'Bâtiment 2', 'code' => 'B2'],
['label' => 'Bâtiment 3', 'code' => 'B3'], ['label' => 'Bâtiment 3', 'code' => 'B3'],
['label' => 'Zone tampon', 'code' => 'ZT'],
]; ];
foreach ($buildings as $buildingData) { foreach ($buildings as $buildingData) {
$this->upsertByCode(Building::class, $buildingData['code'], static function (Building $entity) use ($buildingData) { $this->upsertByCode(Building::class, $buildingData['code'], static function (Building $entity) use ($buildingData) {
@@ -544,7 +545,6 @@ class SeedCommand extends Command
'phone' => '05.49.20.09.10', 'phone' => '05.49.20.09.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LIOT CHATELLERAULT',
'street' => "14 Allée d'Argenson", 'street' => "14 Allée d'Argenson",
'street2' => 'ZI Nord', 'street2' => 'ZI Nord',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -559,7 +559,6 @@ class SeedCommand extends Command
'phone' => '05.49.02.65.27', 'phone' => '05.49.02.65.27',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -574,7 +573,6 @@ class SeedCommand extends Command
'phone' => '06.80.14.18.82', 'phone' => '06.80.14.18.82',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL DES GONNIERES',
'street' => "27 Route d'Ingrandes", 'street' => "27 Route d'Ingrandes",
'street2' => 'Les Gonnières', 'street2' => 'Les Gonnières',
'postalCode' => '86220', 'postalCode' => '86220',
@@ -589,7 +587,6 @@ class SeedCommand extends Command
'phone' => '05.49.86.17.95', 'phone' => '05.49.86.17.95',
'addresses' => [ 'addresses' => [
[ [
'label' => 'EARL LESIGNY BABY',
'street' => '2 Lieu Dit Les Bouquins', 'street' => '2 Lieu Dit Les Bouquins',
'street2' => null, 'street2' => null,
'postalCode' => '86270', 'postalCode' => '86270',
@@ -604,7 +601,6 @@ class SeedCommand extends Command
'phone' => '03.85.24.25.50', 'phone' => '03.85.24.25.50',
'addresses' => [ 'addresses' => [
[ [
'label' => 'FEDER',
'street' => 'Molaise', 'street' => 'Molaise',
'street2' => null, 'street2' => null,
'postalCode' => '71120', 'postalCode' => '71120',
@@ -619,7 +615,6 @@ class SeedCommand extends Command
'phone' => '05.49.86.57.24', 'phone' => '05.49.86.57.24',
'addresses' => [ 'addresses' => [
[ [
'label' => "GAEC DE L'ESPOIR",
'street' => 'La Moujonnerie', 'street' => 'La Moujonnerie',
'street2' => null, 'street2' => null,
'postalCode' => '86450', 'postalCode' => '86450',
@@ -634,7 +629,6 @@ class SeedCommand extends Command
'phone' => '05.49.23.51.66', 'phone' => '05.49.23.51.66',
'addresses' => [ 'addresses' => [
[ [
'label' => 'GRAVELEAU',
'street' => '3, Le Jeu', 'street' => '3, Le Jeu',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -649,7 +643,6 @@ class SeedCommand extends Command
'phone' => '05.49.52.77.10', 'phone' => '05.49.52.77.10',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY',
'street' => 'Ferme de Geniec', 'street' => 'Ferme de Geniec',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -664,7 +657,6 @@ class SeedCommand extends Command
'phone' => '05.65.67.89.46', 'phone' => '05.65.67.89.46',
'addresses' => [ 'addresses' => [
[ [
'label' => 'NATERA',
'street' => 'Bd des Balquières', 'street' => 'Bd des Balquières',
'street2' => 'BP 3220', 'street2' => 'BP 3220',
'postalCode' => '12032', 'postalCode' => '12032',
@@ -674,12 +666,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'SCEA des Bariollières', 'name' => 'SCEA DES BARIOLLIÈRES',
'email' => 'elisregnier@gmail.com', 'email' => 'elisregnier@gmail.com',
'phone' => '06.09.37.65.61', 'phone' => '06.09.37.65.61',
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA des Bariollières',
'street' => '2 rue des Barriollières', 'street' => '2 rue des Barriollières',
'street2' => null, 'street2' => null,
'postalCode' => '86220', 'postalCode' => '86220',
@@ -694,7 +685,6 @@ class SeedCommand extends Command
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'SCEA SENE',
'street' => '3 Route de la Roche Posay', 'street' => '3 Route de la Roche Posay',
'street2' => 'Les Girouettes', 'street2' => 'Les Girouettes',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -709,7 +699,6 @@ class SeedCommand extends Command
'phone' => '02.51.67.17.98', 'phone' => '02.51.67.17.98',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'La Blanchardière', 'street' => 'La Blanchardière',
'street2' => null, 'street2' => null,
'postalCode' => '44522', 'postalCode' => '44522',
@@ -724,7 +713,6 @@ class SeedCommand extends Command
'phone' => '05.49.19.44.33', 'phone' => '05.49.19.44.33',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TRICHERIE COOPERATIVE',
'street' => 'B.P n°2', 'street' => 'B.P n°2',
'street2' => null, 'street2' => null,
'postalCode' => '86490', 'postalCode' => '86490',
@@ -734,12 +722,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'TURPAULT Muriel', 'name' => 'TURPAULT MURIEL',
'email' => null, 'email' => null,
'phone' => null, 'phone' => null,
'addresses' => [ 'addresses' => [
[ [
'label' => 'TURPAULT Muriel',
'street' => '23Bis Rue Marcel Pagnol', 'street' => '23Bis Rue Marcel Pagnol',
'street2' => null, 'street2' => null,
'postalCode' => '86100', 'postalCode' => '86100',
@@ -748,6 +735,34 @@ class SeedCommand extends Command
], ],
], ],
], ],
[
'name' => 'EARL DE LA MENAUDIERE',
'email' => 'frederic.doussineau@orange.fr',
'phone' => '0675446004',
'addresses' => [
[
'street' => '1 la menaudière',
'street2' => null,
'postalCode' => '86450',
'city' => 'LEIGNE LES BOIS ',
'countryCode' => 'FR',
],
],
],
[
'name' => 'SARL ERBS',
'email' => 'touillet.jacques@yahoo.fr',
'phone' => '0675030304',
'addresses' => [
[
'street' => 'les rodières ',
'street2' => null,
'postalCode' => '86230',
'city' => 'Sérigny',
'countryCode' => 'FR',
],
],
],
]; ];
foreach ($suppliers as $supplierData) { foreach ($suppliers as $supplierData) {
@@ -780,7 +795,6 @@ class SeedCommand extends Command
'email' => 'eurl.arnault86@orange.fr', 'email' => 'eurl.arnault86@orange.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'ARNAULT EURL',
'street' => 'Moulin du Guéret', 'street' => 'Moulin du Guéret',
'street2' => 'B.P 30425', 'street2' => 'B.P 30425',
'postalCode' => '86100', 'postalCode' => '86100',
@@ -795,7 +809,6 @@ class SeedCommand extends Command
'email' => 'sandra.robineaux@covilim.com', 'email' => 'sandra.robineaux@covilim.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'COVILIM',
'street' => 'Rue de Nexon', 'street' => 'Rue de Nexon',
'street2' => null, 'street2' => null,
'postalCode' => '87000', 'postalCode' => '87000',
@@ -805,12 +818,11 @@ class SeedCommand extends Command
], ],
], ],
[ [
'name' => 'Les producteurs de la marche (LPM)', 'name' => 'LES PRODUCTEURS DE LA MARCHE (LPM)',
'phone' => '05.55.63.04.53', 'phone' => '05.55.63.04.53',
'email' => 'f.legalliard@lpmcoop.fr', 'email' => 'f.legalliard@lpmcoop.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'Les producteurs de la marche (LPM)',
'street' => 'Malonze', 'street' => 'Malonze',
'street2' => null, 'street2' => null,
'postalCode' => '23300', 'postalCode' => '23300',
@@ -825,7 +837,6 @@ class SeedCommand extends Command
'email' => 'contact86@lortholarybetail.com', 'email' => 'contact86@lortholarybetail.com',
'addresses' => [ 'addresses' => [
[ [
'label' => 'LORTHOLARY BETAIL',
'street' => 'FERME DE GENIEC', 'street' => 'FERME DE GENIEC',
'street2' => null, 'street2' => null,
'postalCode' => '86550', 'postalCode' => '86550',
@@ -840,7 +851,6 @@ class SeedCommand extends Command
'email' => 'scouillaud@terrena.fr', 'email' => 'scouillaud@terrena.fr',
'addresses' => [ 'addresses' => [
[ [
'label' => 'TERRENA',
'street' => 'LA NOELLE', 'street' => 'LA NOELLE',
'street2' => 'BP 20199', 'street2' => 'BP 20199',
'postalCode' => '44155', 'postalCode' => '44155',
@@ -930,7 +940,7 @@ class SeedCommand extends Command
{ {
$addressRepo = $this->entityManager->getRepository(Address::class); $addressRepo = $this->entityManager->getRepository(Address::class);
$address = $addressRepo->findOneBy([ $address = $addressRepo->findOneBy([
'label' => $addressData['label'], 'street' => $addressData['street'],
'postalCode' => $addressData['postalCode'], 'postalCode' => $addressData['postalCode'],
]); ]);
if (!$address) { if (!$address) {
@@ -940,7 +950,6 @@ class SeedCommand extends Command
++$this->updated; ++$this->updated;
} }
$address $address
->setLabel($addressData['label'])
->setStreet($addressData['street']) ->setStreet($addressData['street'])
->setStreet2($addressData['street2']) ->setStreet2($addressData['street2'])
->setPostalCode($addressData['postalCode']) ->setPostalCode($addressData['postalCode'])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter;
use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
@@ -30,6 +31,7 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
#[ORM\Table(name: 'shipment')] #[ORM\Table(name: 'shipment')]
#[ApiFilter(BooleanFilter::class, properties: ['isValid'])] #[ApiFilter(BooleanFilter::class, properties: ['isValid'])]
#[ApiResource( #[ApiResource(
order: ['id' => 'DESC'],
operations: [ operations: [
new Get( new Get(
requirements: ['id' => '\d+'], requirements: ['id' => '\d+'],
@@ -47,6 +49,9 @@ use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
normalizationContext: ['groups' => ['shipment:read']], normalizationContext: ['groups' => ['shipment:read']],
denormalizationContext: ['groups' => ['shipment:write']], denormalizationContext: ['groups' => ['shipment:write']],
), ),
new Delete(
requirements: ['id' => '\d+'],
),
new Get( new Get(
uriTemplate: '/shipments/weigh', uriTemplate: '/shipments/weigh',
openapi: new OpenApiOperation( openapi: new OpenApiOperation(
@@ -102,7 +107,10 @@ class Shipment
#[ORM\Column(name: 'shipment_date', type: 'datetime_immutable')] #[ORM\Column(name: 'shipment_date', type: 'datetime_immutable')]
#[Groups(['shipment:read', 'shipment:write'])] #[Groups(['shipment:read', 'shipment:write'])]
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])] #[Context(
normalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d H:i'],
denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'],
)]
private ?DateTimeImmutable $shipmentDate = null; private ?DateTimeImmutable $shipmentDate = null;
#[ORM\ManyToOne] #[ORM\ManyToOne]
@@ -245,6 +253,13 @@ class Shipment
public function setShipmentDate(?DateTimeImmutable $shipmentDate): void public function setShipmentDate(?DateTimeImmutable $shipmentDate): void
{ {
if (null !== $shipmentDate && null !== $this->shipmentDate) {
$shipmentDate = $shipmentDate->setTime(
(int) $this->shipmentDate->format('H'),
(int) $this->shipmentDate->format('i'),
(int) $this->shipmentDate->format('s'),
);
}
$this->shipmentDate = $shipmentDate; $this->shipmentDate = $shipmentDate;
} }
@@ -303,6 +318,21 @@ class Shipment
} }
} }
#[ORM\PrePersist]
public function initializeShipmentDate(): void
{
$now = new DateTimeImmutable();
if (null === $this->shipmentDate) {
$this->shipmentDate = $now;
} else {
$this->shipmentDate = $this->shipmentDate->setTime(
(int) $now->format('H'),
(int) $now->format('i'),
(int) $now->format('s'),
);
}
}
#[ORM\PostPersist] #[ORM\PostPersist]
public function initializeIdentificationNumber(PostPersistEventArgs $args): void public function initializeIdentificationNumber(PostPersistEventArgs $args): void
{ {

View File

@@ -61,6 +61,11 @@ class Supplier
#[Groups(['supplier:read', 'reception:read', 'supplier:write'])] #[Groups(['supplier:read', 'reception:read', 'supplier:write'])]
private ?string $phone = null; private ?string $phone = null;
#[ORM\ManyToOne(targetEntity: User::class)]
#[ORM\JoinColumn(name: 'created_by', nullable: true)]
#[Groups(['supplier:read', 'supplier:write'])]
private ?User $createdBy = null;
/** /**
* @var Collection<int, Address> * @var Collection<int, Address>
*/ */
@@ -87,7 +92,7 @@ class Supplier
public function setName(string $name): self public function setName(string $name): self
{ {
$this->name = $name; $this->name = mb_strtoupper($name);
return $this; return $this;
} }
@@ -116,6 +121,18 @@ class Supplier
return $this; return $this;
} }
public function getCreatedBy(): ?User
{
return $this->createdBy;
}
public function setCreatedBy(?User $createdBy): self
{
$this->createdBy = $createdBy;
return $this;
}
/** /**
* @return Collection<int, Address> * @return Collection<int, Address>
*/ */

View File

@@ -61,11 +61,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')] #[ORM\Column(type: 'integer')]
#[Groups(['user:read', 'user-login:read', 'reception:read', 'shipment:read'])] #[Groups(['user:read', 'user-login:read', 'reception:read', 'shipment:read', 'supplier:read', 'customer:read'])]
private ?int $id = null; private ?int $id = null;
#[ORM\Column(length: 180, unique: true)] #[ORM\Column(length: 180, unique: true)]
#[Groups(['user:read', 'user:write', 'user-login:read', 'reception:read', 'shipment:read'])] #[Groups(['user:read', 'user:write', 'user-login:read', 'reception:read', 'shipment:read', 'supplier:read', 'customer:read'])]
private string $username = ''; private string $username = '';
#[ORM\Column(type: 'json')] #[ORM\Column(type: 'json')]