Compare commits

..

18 Commits

Author SHA1 Message Date
16abc30b9f Merge branch 'develop' into feat/276-lister-expeditions-terminees
# Conflicts:
#	CHANGELOG.md
2026-02-12 09:58:16 +01:00
7e2f23c6b6 Merge remote-tracking branch 'origin/develop' into feat/276-lister-expeditions-terminees
# Conflicts:
#	CHANGELOG.md
2026-02-12 09:24:56 +01:00
886b90215a feat : lister les expeditions terminees 2026-02-12 08:58:29 +01:00
265e85b8f4 Merge branch 'refs/heads/develop' into feat/276-lister-expeditions-terminees 2026-02-12 08:57:48 +01:00
b4e024590e feat : changelog 2026-02-12 08:30:34 +01:00
74af5d30ba feat : ajout d'une page de creation d'une expedition 2026-02-12 08:19:47 +01:00
b905cb66aa Merge branch 'refs/heads/develop' into feat/271-expedition-etape-1
# Conflicts:
#	CHANGELOG.md
2026-02-12 08:18:17 +01:00
5f4139fde3 feat : ajout d'une page de creation d'une expedition 2026-02-11 16:12:33 +01:00
0181d72144 Merge branch 'develop' into feat/271-expedition-etape-1 2026-02-11 08:03:12 +01:00
bffdc88701 feat : creer une nouvelle expedtion (WIP) 2026-02-10 16:16:05 +01:00
42c5a3b2d2 Merge branch 'develop' into feat/271-expedition-etape-1
# Conflicts:
#	.idea/data_source_mapping.xml
#	src/Entity/Carrier.php
2026-02-10 08:59:24 +01:00
28d5bc7599 Merge remote-tracking branch 'refs/remotes/origin/develop' into feat/271-expedition-etape-1
# Conflicts:
#	.idea/workspace.xml
2026-02-05 10:47:05 +01:00
69d047fca0 feat : #271 -Créer une nouvelle expédition 2026-02-05 09:10:52 +01:00
ba4375f609 feat : Expedition dev back-end WIP 2026-02-04 16:58:55 +01:00
e249d44e78 feat : mise à jour du bon de réception WIP 2026-02-03 17:34:35 +01:00
e8189a4d04 feat : mise à jour du bon de réception WIP 2026-02-03 17:32:39 +01:00
081c2ef403 feat : mise à jour du bon de réception WIP 2026-02-03 17:16:47 +01:00
5fd2ab8470 feat : mise à jour du bon de réception wip 2026-02-03 17:16:19 +01:00
158 changed files with 3503 additions and 10106 deletions

View File

@@ -1,10 +0,0 @@
{
"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: |
cd frontend
npm ci
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
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html
- name: Build artefact

19
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?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>

10
.idea/data_source_mapping.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console_1.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console_2.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console_3.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/f407a514-c6b4-4b26-9555-445a85892502/console_4.sql" value="f407a514-c6b4-4b26-9555-445a85892502" />
</component>
</project>

View File

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

706
.idea/workspace.xml generated
View File

@@ -4,12 +4,28 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente">
<list default="true" id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)">
<change afterPath="$PROJECT_DIR$/frontend/components/shipment/shipment-form.vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/pages/shipment/[[id]].vue" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/bovin-shipment.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/customer.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/dto/bovin-shipment-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/dto/customer-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/dto/shipment-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/dto/shipment-type-data.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/shipment-type.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/services/shipment.ts" afterDir="false" />
<change afterPath="$PROJECT_DIR$/frontend/stores/shipment.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CHANGELOG.md" beforeDir="false" afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/config/reference.php" beforeDir="false" afterPath="$PROJECT_DIR$/config/reference.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/reception/waiting-reception.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/shipment/waiting-shipment.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/components/ui/UiNumberInput.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/components/ui/UiNumberInput.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/constants/steps.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/constants/steps.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/i18n/locales/fr.json" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/i18n/locales/fr.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/pages/index.vue" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/pages/index.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/frontend/services/reception.ts" beforeDir="false" afterPath="$PROJECT_DIR$/frontend/services/reception.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/Address.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Address.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/BovinShipment.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/BovinShipment.php" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/Entity/Shipment.php" beforeDir="false" afterPath="$PROJECT_DIR$/src/Entity/Shipment.php" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -32,23 +48,19 @@
<component name="FileTemplateManagerImpl">
<option name="RECENT_TEMPLATES">
<list>
<option value="TypeScript File" />
<option value="PHP File" />
<option value="Vue Composition API Component" />
<option value="TypeScript File" />
</list>
</option>
</component>
<component name="Git.Settings">
<option name="RECENT_BRANCH_BY_REPOSITORY">
<map>
<entry key="$PROJECT_DIR$" value="feature/FER-13-faire-des-recherches-sur-le-scanner-des-betes" />
<entry key="$PROJECT_DIR$" value="fix/makefile" />
</map>
</option>
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="file://$PROJECT_DIR$/frontend/pages/admin/supplier/supplier-list.vue" root0="FORCE_HIGHLIGHTING" />
</component>
<component name="McpProjectServerCommands">
<commands />
<urls />
@@ -232,14 +244,14 @@
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
"RunOnceActivity.git.unshallow": "true",
"RunOnceActivity.typescript.service.memoryLimit.init": "true",
"git-widget-placeholder": "fix/FER-15-fix-droit-de-suppression-reception-expedition-util",
"last_opened_file_path": "//wsl.localhost/Ubuntu-24.04/home/m-tristan/workspace/Ferme",
"git-widget-placeholder": "feat/271-expedition-etape-1",
"last_opened_file_path": "/home/sroy/Documents/test/Ferme",
"node.js.detected.package.eslint": "true",
"node.js.detected.package.tslint": "true",
"node.js.selected.package.eslint": "(autodetect)",
"node.js.selected.package.tslint": "(autodetect)",
"nodejs_package_manager_path": "npm",
"settings.editor.selected.configurable": "advanced.settings",
"settings.editor.selected.configurable": "configurable.tailwindcss",
"ts.external.directory.path": "/opt/phpstorm/plugins/javascript-plugin/jsLanguageServicesImpl/external",
"vue.rearranger.settings.migration": "true"
},
@@ -256,29 +268,21 @@
}
}]]></component>
<component name="RecentsManager">
<key name="CopyFile.RECENT_KEYS">
<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\composables" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\kevin\Stage\Ferme\frontend\components\shipment" />
</key>
<key name="MoveFile.RECENT_KEYS">
<recent name="\\wsl.localhost\Ubuntu-24.04\home\m-tristan\workspace\Ferme" />
<recent name="C:\Users\m-tristan\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\templates" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches" />
<recent name="C:\Users\autin\AppData\Roaming\JetBrains\PhpStorm2025.3\scratches\Ferme_MCD\MCD_DOC" />
<recent name="\\wsl.localhost\Ubuntu-24.04\home\tristan\workspace\ferme\frontend\pages\reception" />
</key>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.32098.40" />
<option value="bundled-php-predefined-a98d8de5180a-0e0d91225499-com.jetbrains.php.sharedIndexes-PS-253.30387.85" />
</set>
</attachedChunks>
</component>
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="7c107abe-5995-4428-8429-b146aaca8386" name="Changes" comment="" />
@@ -309,24 +313,294 @@
<workItem from="1770195718952" duration="215000" />
<workItem from="1770195959162" duration="18915000" />
<workItem from="1770274844804" duration="3940000" />
<workItem from="1770798536017" duration="20774000" />
<workItem from="1770879701502" duration="25805000" />
<workItem from="1770966186589" duration="914000" />
<workItem from="1770967274060" duration="2388000" />
<workItem from="1772466451823" duration="598000" />
<workItem from="1772626984813" duration="969000" />
<workItem from="1772786360430" duration="21000" />
<workItem from="1772786475316" duration="3016000" />
<workItem from="1773049125640" duration="406000" />
<workItem from="1773049540928" duration="539000" />
<workItem from="1773050154207" duration="1879000" />
<workItem from="1773212999001" duration="652000" />
<workItem from="1773215356754" duration="5754000" />
<workItem from="1773756072697" duration="5450000" />
<workItem from="1773766075191" duration="6202000" />
<workItem from="1773824491213" duration="24805000" />
<workItem from="1774275549972" duration="51000" />
<workItem from="1774276665015" duration="33750000" />
</task>
<task id="LOCAL-00001" summary="feat : Ajout de pinia, création de la table weight et reception mise en place du système de step pour les receptions (WIP)">
<option name="closed" value="true" />
<created>1768237763998</created>
<option name="number" value="00001" />
<option name="presentableId" value="LOCAL-00001" />
<option name="project" value="LOCAL" />
<updated>1768237763998</updated>
</task>
<task id="LOCAL-00002" summary="feat : Ajout de zod, création d'un composant de chargement loading-dots.vue et finalisation du flow d'une reception">
<option name="closed" value="true" />
<created>1768316052474</created>
<option name="number" value="00002" />
<option name="presentableId" value="LOCAL-00002" />
<option name="project" value="LOCAL" />
<updated>1768316052474</updated>
</task>
<task id="LOCAL-00003" summary="feat : Ajout d'un composable pour la pesée qui sera réutilisable pour l'expédition, ajout de contrainte sur les entity de reception et weight pour plus de robustesse et correction de la class active des liens dans la nav">
<option name="closed" value="true" />
<created>1768316835575</created>
<option name="number" value="00003" />
<option name="presentableId" value="LOCAL-00003" />
<option name="project" value="LOCAL" />
<updated>1768316835575</updated>
</task>
<task id="LOCAL-00004" summary="feat : update du fichier AGENTS.md">
<option name="closed" value="true" />
<created>1768316965511</created>
<option name="number" value="00004" />
<option name="presentableId" value="LOCAL-00004" />
<option name="project" value="LOCAL" />
<updated>1768316965511</updated>
</task>
<task id="LOCAL-00005" summary="feat : update du fichier README.md et CHANGELOG.md">
<option name="closed" value="true" />
<created>1768317786187</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1768317786187</updated>
</task>
<task id="LOCAL-00006" summary="fix : correction du useApi pour qu'il n'y ait plus de retry lors d'une erreur 500 par exemple">
<option name="closed" value="true" />
<created>1768318875533</created>
<option name="number" value="00006" />
<option name="presentableId" value="LOCAL-00006" />
<option name="project" value="LOCAL" />
<updated>1768318875533</updated>
</task>
<task id="LOCAL-00007" summary="test : ajout de TU sur les services et providers">
<option name="closed" value="true" />
<created>1768318921478</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1768318921478</updated>
</task>
<task id="LOCAL-00008" summary="feat : ajout de la génération du bon de reception, correction de la base du formulaire multi-etape de reception et ajout d'une gestion d'erreur global">
<option name="closed" value="true" />
<created>1768498751836</created>
<option name="number" value="00008" />
<option name="presentableId" value="LOCAL-00008" />
<option name="project" value="LOCAL" />
<updated>1768498751836</updated>
</task>
<task id="LOCAL-00009" summary="feat : ajout d'une gestion d'erreur au global côté front avec la lib toaster et I18n pour centraliser les messages d'erreur">
<option name="closed" value="true" />
<created>1768555180530</created>
<option name="number" value="00009" />
<option name="presentableId" value="LOCAL-00009" />
<option name="project" value="LOCAL" />
<updated>1768555180530</updated>
</task>
<task id="LOCAL-00010" summary="feat : ajout de l'authentification avec lexik">
<option name="closed" value="true" />
<created>1768832208350</created>
<option name="number" value="00010" />
<option name="presentableId" value="LOCAL-00010" />
<option name="project" value="LOCAL" />
<updated>1768832208350</updated>
</task>
<task id="LOCAL-00011" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1768832516587</created>
<option name="number" value="00011" />
<option name="presentableId" value="LOCAL-00011" />
<option name="project" value="LOCAL" />
<updated>1768832516587</updated>
</task>
<task id="LOCAL-00012" summary="fix : correction de l'accès au swagger en mode dev qui n'était plus accessible">
<option name="closed" value="true" />
<created>1768940104944</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1768940104944</updated>
</task>
<task id="LOCAL-00013" summary="feat : ajout de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769005220331</created>
<option name="number" value="00013" />
<option name="presentableId" value="LOCAL-00013" />
<option name="project" value="LOCAL" />
<updated>1769005220331</updated>
</task>
<task id="LOCAL-00014" summary="fix : fix de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769008700008</created>
<option name="number" value="00014" />
<option name="presentableId" value="LOCAL-00014" />
<option name="project" value="LOCAL" />
<updated>1769008700008</updated>
</task>
<task id="LOCAL-00015" summary="fix : fix de la conf pour le déploiement en recette">
<option name="closed" value="true" />
<created>1769014602062</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1769014602062</updated>
</task>
<task id="LOCAL-00016" summary="fix : migration apache vers nginx pour un déploiement plus simple">
<option name="closed" value="true" />
<created>1769019284586</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1769019284586</updated>
</task>
<task id="LOCAL-00017" summary="fix : dernière modification pour le déploiement en recette et le changement de conf vers nginx">
<option name="closed" value="true" />
<created>1769021756823</created>
<option name="number" value="00017" />
<option name="presentableId" value="LOCAL-00017" />
<option name="project" value="LOCAL" />
<updated>1769021756823</updated>
</task>
<task id="LOCAL-00018" summary="ci : auto tag + release artefact">
<option name="closed" value="true" />
<created>1769021818384</created>
<option name="number" value="00018" />
<option name="presentableId" value="LOCAL-00018" />
<option name="project" value="LOCAL" />
<updated>1769021818384</updated>
</task>
<task id="LOCAL-00019" summary="ci : fix release artefact">
<option name="closed" value="true" />
<created>1769022071620</created>
<option name="number" value="00019" />
<option name="presentableId" value="LOCAL-00019" />
<option name="project" value="LOCAL" />
<updated>1769022071620</updated>
</task>
<task id="LOCAL-00020" summary="ci : fix release artefact">
<option name="closed" value="true" />
<created>1769024603812</created>
<option name="number" value="00020" />
<option name="presentableId" value="LOCAL-00020" />
<option name="project" value="LOCAL" />
<updated>1769024603812</updated>
</task>
<task id="LOCAL-00021" summary="ci : ajout du script et de la doc déploiement">
<option name="closed" value="true" />
<created>1769026716634</created>
<option name="number" value="00021" />
<option name="presentableId" value="LOCAL-00021" />
<option name="project" value="LOCAL" />
<updated>1769026716634</updated>
</task>
<task id="LOCAL-00022" summary="fix : correction du path URI pour la création d'un poids dans une réception">
<option name="closed" value="true" />
<created>1769073690382</created>
<option name="number" value="00022" />
<option name="presentableId" value="LOCAL-00022" />
<option name="project" value="LOCAL" />
<updated>1769073690382</updated>
</task>
<task id="LOCAL-00023" summary="feat : Ajout du bundle Monolog pour la gestion des logs">
<option name="closed" value="true" />
<created>1769075990984</created>
<option name="number" value="00023" />
<option name="presentableId" value="LOCAL-00023" />
<option name="project" value="LOCAL" />
<updated>1769075990984</updated>
</task>
<task id="LOCAL-00024" summary="fix : affiche plus détail dans les logs en recette/prod">
<option name="closed" value="true" />
<created>1769077633390</created>
<option name="number" value="00024" />
<option name="presentableId" value="LOCAL-00024" />
<option name="project" value="LOCAL" />
<updated>1769077633390</updated>
</task>
<task id="LOCAL-00025" summary="fix : modification du script de déploiement pour corriger le problème d'écriture des logs de prod">
<option name="closed" value="true" />
<created>1769079030808</created>
<option name="number" value="00025" />
<option name="presentableId" value="LOCAL-00025" />
<option name="project" value="LOCAL" />
<updated>1769079030808</updated>
</task>
<task id="LOCAL-00026" summary="fix : doc de déploiement">
<option name="closed" value="true" />
<created>1769094376813</created>
<option name="number" value="00026" />
<option name="presentableId" value="LOCAL-00026" />
<option name="project" value="LOCAL" />
<updated>1769094376813</updated>
</task>
<task id="LOCAL-00027" summary="fix : doc et script de déploiement">
<option name="closed" value="true" />
<created>1769096187792</created>
<option name="number" value="00027" />
<option name="presentableId" value="LOCAL-00027" />
<option name="project" value="LOCAL" />
<updated>1769096187792</updated>
</task>
<task id="LOCAL-00028" summary="fix : doc et script de déploiement">
<option name="closed" value="true" />
<created>1769097091268</created>
<option name="number" value="00028" />
<option name="presentableId" value="LOCAL-00028" />
<option name="project" value="LOCAL" />
<updated>1769097091268</updated>
</task>
<task id="LOCAL-00029" summary="fix : gitea workflow">
<option name="closed" value="true" />
<created>1769097476629</created>
<option name="number" value="00029" />
<option name="presentableId" value="LOCAL-00029" />
<option name="project" value="LOCAL" />
<updated>1769097476629</updated>
</task>
<task id="LOCAL-00030" summary="fix : script de déploiement">
<option name="closed" value="true" />
<created>1769098182184</created>
<option name="number" value="00030" />
<option name="presentableId" value="LOCAL-00030" />
<option name="project" value="LOCAL" />
<updated>1769098182184</updated>
</task>
<task id="LOCAL-00031" summary="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil">
<option name="closed" value="true" />
<created>1769098861988</created>
<option name="number" value="00031" />
<option name="presentableId" value="LOCAL-00031" />
<option name="project" value="LOCAL" />
<updated>1769098861988</updated>
</task>
<task id="LOCAL-00032" summary="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster">
<option name="closed" value="true" />
<created>1769100048933</created>
<option name="number" value="00032" />
<option name="presentableId" value="LOCAL-00032" />
<option name="project" value="LOCAL" />
<updated>1769100048933</updated>
</task>
<task id="LOCAL-00033" summary="feat : ajout de la debug bar en mod dev">
<option name="closed" value="true" />
<created>1769177611987</created>
<option name="number" value="00033" />
<option name="presentableId" value="LOCAL-00033" />
<option name="project" value="LOCAL" />
<updated>1769177611987</updated>
</task>
<task id="LOCAL-00034" summary="feat : ajout du bundle Malio ednotif pour l'utilisation des WS">
<option name="closed" value="true" />
<created>1769184861047</created>
<option name="number" value="00034" />
<option name="presentableId" value="LOCAL-00034" />
<option name="project" value="LOCAL" />
<updated>1769184861047</updated>
</task>
<task id="LOCAL-00035" summary="fix : modification de la conf du bundle ednotif">
<option name="closed" value="true" />
<created>1769434793487</created>
<option name="number" value="00035" />
<option name="presentableId" value="LOCAL-00035" />
<option name="project" value="LOCAL" />
<updated>1769434793487</updated>
</task>
<task id="LOCAL-00036" summary="feat : update du CHANGELOG.md">
<option name="closed" value="true" />
<created>1769435038236</created>
<option name="number" value="00036" />
<option name="presentableId" value="LOCAL-00036" />
<option name="project" value="LOCAL" />
<updated>1769435038236</updated>
</task>
<task id="LOCAL-00037" summary="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)">
<option name="closed" value="true" />
@@ -432,295 +706,7 @@
<option name="project" value="LOCAL" />
<updated>1770217875423</updated>
</task>
<task id="LOCAL-00050" summary="feat : creer une nouvelle expedtion (WIP)">
<option name="closed" value="true" />
<created>1770736570645</created>
<option name="number" value="00050" />
<option name="presentableId" value="LOCAL-00050" />
<option name="project" value="LOCAL" />
<updated>1770736570645</updated>
</task>
<task id="LOCAL-00051" summary="feat : ajout d'une page de creation d'une expedition">
<option name="closed" value="true" />
<created>1770880791564</created>
<option name="number" value="00051" />
<option name="presentableId" value="LOCAL-00051" />
<option name="project" value="LOCAL" />
<updated>1770880791565</updated>
</task>
<task id="LOCAL-00052" summary="feat : changelog">
<option name="closed" value="true" />
<created>1770881437439</created>
<option name="number" value="00052" />
<option name="presentableId" value="LOCAL-00052" />
<option name="project" value="LOCAL" />
<updated>1770881437439</updated>
</task>
<task id="LOCAL-00053" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770883114609</created>
<option name="number" value="00053" />
<option name="presentableId" value="LOCAL-00053" />
<option name="project" value="LOCAL" />
<updated>1770883114609</updated>
</task>
<task id="LOCAL-00054" summary="feat : lister les expeditions terminees">
<option name="closed" value="true" />
<created>1770884154297</created>
<option name="number" value="00054" />
<option name="presentableId" value="LOCAL-00054" />
<option name="project" value="LOCAL" />
<updated>1770884154297</updated>
</task>
<task id="LOCAL-00055" summary="fix : corrections diverses">
<option name="closed" value="true" />
<created>1770969471135</created>
<option name="number" value="00055" />
<option name="presentableId" value="LOCAL-00055" />
<option name="project" value="LOCAL" />
<updated>1770969471135</updated>
</task>
<task id="LOCAL-00056" summary="fix : corrections frontend">
<option name="closed" value="true" />
<created>1772094268366</created>
<option name="number" value="00056" />
<option name="presentableId" value="LOCAL-00056" />
<option name="project" value="LOCAL" />
<updated>1772094268366</updated>
</task>
<task id="LOCAL-00057" summary="feat : affichage et modification expédition et modification bouton valider">
<option name="closed" value="true" />
<created>1772111964268</created>
<option name="number" value="00057" />
<option name="presentableId" value="LOCAL-00057" />
<option name="project" value="LOCAL" />
<updated>1772111964268</updated>
</task>
<task id="LOCAL-00058" summary="fix : erreur customer adress et bouton valider oublie">
<option name="closed" value="true" />
<created>1772112729501</created>
<option name="number" value="00058" />
<option name="presentableId" value="LOCAL-00058" />
<option name="project" value="LOCAL" />
<updated>1772112729502</updated>
</task>
<task id="LOCAL-00059" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772112812677</created>
<option name="number" value="00059" />
<option name="presentableId" value="LOCAL-00059" />
<option name="project" value="LOCAL" />
<updated>1772112812677</updated>
</task>
<task id="LOCAL-00060" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772177400063</created>
<option name="number" value="00060" />
<option name="presentableId" value="LOCAL-00060" />
<option name="project" value="LOCAL" />
<updated>1772177400063</updated>
</task>
<task id="LOCAL-00061" summary="feat : changelog update">
<option name="closed" value="true" />
<created>1772177614438</created>
<option name="number" value="00061" />
<option name="presentableId" value="LOCAL-00061" />
<option name="project" value="LOCAL" />
<updated>1772177614438</updated>
</task>
<task id="LOCAL-00062" summary="fix : color tab">
<option name="closed" value="true" />
<created>1772178540489</created>
<option name="number" value="00062" />
<option name="presentableId" value="LOCAL-00062" />
<option name="project" value="LOCAL" />
<updated>1772178540489</updated>
</task>
<task id="LOCAL-00063" summary="feat : modification front de la page admin transporteur">
<option name="closed" value="true" />
<created>1772180053740</created>
<option name="number" value="00063" />
<option name="presentableId" value="LOCAL-00063" />
<option name="project" value="LOCAL" />
<updated>1772180053740</updated>
</task>
<task id="LOCAL-00064" summary="fix : espacement et changelog">
<option name="closed" value="true" />
<created>1772180581178</created>
<option name="number" value="00064" />
<option name="presentableId" value="LOCAL-00064" />
<option name="project" value="LOCAL" />
<updated>1772180581178</updated>
</task>
<task id="LOCAL-00065" summary="fix : espacement">
<option name="closed" value="true" />
<created>1772180684250</created>
<option name="number" value="00065" />
<option name="presentableId" value="LOCAL-00065" />
<option name="project" value="LOCAL" />
<updated>1772180684250</updated>
</task>
<task id="LOCAL-00066" summary="fix : espacement">
<option name="closed" value="true" />
<created>1772180972984</created>
<option name="number" value="00066" />
<option name="presentableId" value="LOCAL-00066" />
<option name="project" value="LOCAL" />
<updated>1772180972984</updated>
</task>
<task id="LOCAL-00067" summary="fix : text">
<option name="closed" value="true" />
<created>1772182545592</created>
<option name="number" value="00067" />
<option name="presentableId" value="LOCAL-00067" />
<option name="project" value="LOCAL" />
<updated>1772182545592</updated>
</task>
<task id="LOCAL-00068" summary="feat : front page admin bovin et changelog">
<option name="closed" value="true" />
<created>1772182707441</created>
<option name="number" value="00068" />
<option name="presentableId" value="LOCAL-00068" />
<option name="project" value="LOCAL" />
<updated>1772182707441</updated>
</task>
<task id="LOCAL-00069" summary="fix : on ne bloque plus le poids max d'une pesée">
<option name="closed" value="true" />
<created>1772447581744</created>
<option name="number" value="00069" />
<option name="presentableId" value="LOCAL-00069" />
<option name="project" value="LOCAL" />
<updated>1772447581744</updated>
</task>
<task id="LOCAL-00070" summary="feat : ajout de supplier dans la feed et fixtures">
<option name="closed" value="true" />
<created>1773761787472</created>
<option name="number" value="00070" />
<option name="presentableId" value="LOCAL-00070" />
<option name="project" value="LOCAL" />
<updated>1773761787472</updated>
</task>
<task id="LOCAL-00071" summary="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu">
<option name="closed" value="true" />
<created>1773766207721</created>
<option name="number" value="00071" />
<option name="presentableId" value="LOCAL-00071" />
<option name="project" value="LOCAL" />
<updated>1773766207721</updated>
</task>
<task id="LOCAL-00072" summary="fix : on ne pèse plus automatiquement + fix message de création réception">
<option name="closed" value="true" />
<created>1773826699115</created>
<option name="number" value="00072" />
<option name="presentableId" value="LOCAL-00072" />
<option name="project" value="LOCAL" />
<updated>1773826699115</updated>
</task>
<task id="LOCAL-00073" summary="fix : correction des retours de la V0">
<option name="closed" value="true" />
<created>1773841634554</created>
<option name="number" value="00073" />
<option name="presentableId" value="LOCAL-00073" />
<option name="project" value="LOCAL" />
<updated>1773841634554</updated>
</task>
<task id="LOCAL-00074" summary="feat : ajout de l'api de l'état pour chercher les villes via le CP">
<option name="closed" value="true" />
<created>1773842791819</created>
<option name="number" value="00074" />
<option name="presentableId" value="LOCAL-00074" />
<option name="project" value="LOCAL" />
<updated>1773842791819</updated>
</task>
<task id="LOCAL-00075" summary="fix : script de déploiement + CI/CD build de l'app">
<option name="closed" value="true" />
<created>1773843922376</created>
<option name="number" value="00075" />
<option name="presentableId" value="LOCAL-00075" />
<option name="project" value="LOCAL" />
<updated>1773843922377</updated>
</task>
<task id="LOCAL-00076" summary="fix : order navbar + modification création fournisseur et client">
<option name="closed" value="true" />
<created>1773852806120</created>
<option name="number" value="00076" />
<option name="presentableId" value="LOCAL-00076" />
<option name="project" value="LOCAL" />
<updated>1773852806121</updated>
</task>
<task id="LOCAL-00077" summary="fix : order récéption/expédition + correction style bouton récéption">
<option name="closed" value="true" />
<created>1774283204849</created>
<option name="number" value="00077" />
<option name="presentableId" value="LOCAL-00077" />
<option name="project" value="LOCAL" />
<updated>1774283204849</updated>
</task>
<task id="LOCAL-00078" summary="fix : style bon de récéption">
<option name="closed" value="true" />
<created>1774285464091</created>
<option name="number" value="00078" />
<option name="presentableId" value="LOCAL-00078" />
<option name="project" value="LOCAL" />
<updated>1774285464091</updated>
</task>
<task id="LOCAL-00079" summary="fix : bouton de mise en attente">
<option name="closed" value="true" />
<created>1774337609427</created>
<option name="number" value="00079" />
<option name="presentableId" value="LOCAL-00079" />
<option name="project" value="LOCAL" />
<updated>1774337609427</updated>
</task>
<task id="LOCAL-00080" summary="fix : problème de bearer token">
<option name="closed" value="true" />
<created>1774448105945</created>
<option name="number" value="00080" />
<option name="presentableId" value="LOCAL-00080" />
<option name="project" value="LOCAL" />
<updated>1774448105945</updated>
</task>
<task id="LOCAL-00081" summary="feat : système de blocage utilisateur">
<option name="closed" value="true" />
<created>1774450388149</created>
<option name="number" value="00081" />
<option name="presentableId" value="LOCAL-00081" />
<option name="project" value="LOCAL" />
<updated>1774450388149</updated>
</task>
<task id="LOCAL-00082" summary="feat : ajout d'un système de scanner bovin">
<option name="closed" value="true" />
<created>1774543296474</created>
<option name="number" value="00082" />
<option name="presentableId" value="LOCAL-00082" />
<option name="project" value="LOCAL" />
<updated>1774543296474</updated>
</task>
<task id="LOCAL-00083" summary="feat : mise à jour du CLAUDE.md">
<option name="closed" value="true" />
<created>1774543626516</created>
<option name="number" value="00083" />
<option name="presentableId" value="LOCAL-00083" />
<option name="project" value="LOCAL" />
<updated>1774543626516</updated>
</task>
<task id="LOCAL-00084" summary="feat : update CHANGELOG.md">
<option name="closed" value="true" />
<created>1774543766582</created>
<option name="number" value="00084" />
<option name="presentableId" value="LOCAL-00084" />
<option name="project" value="LOCAL" />
<updated>1774543766582</updated>
</task>
<task id="LOCAL-00085" summary="feat : la page de scanner est accessible que pour les admins">
<option name="closed" value="true" />
<created>1774543840891</created>
<option name="number" value="00085" />
<option name="presentableId" value="LOCAL-00085" />
<option name="project" value="LOCAL" />
<updated>1774543840891</updated>
</task>
<option name="localTasksCounter" value="86" />
<option name="localTasksCounter" value="50" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -770,32 +756,32 @@
</option>
</component>
<component name="VcsManagerConfiguration">
<MESSAGE value="feat : changelog update" />
<MESSAGE value="fix : color tab" />
<MESSAGE value="feat : modification front de la page admin transporteur" />
<MESSAGE value="fix : espacement et changelog" />
<MESSAGE value="fix : espacement" />
<MESSAGE value="fix : text" />
<MESSAGE value="feat : front page admin bovin et changelog" />
<MESSAGE value="fix : on ne bloque plus le poids max d'une pesée" />
<MESSAGE value="feat : ajout de supplier dans la feed et fixtures" />
<MESSAGE value="feat : ajout de bâtiment dans les fixtures et seed + organisation du menu" />
<MESSAGE value="fix : on ne pèse plus automatiquement + fix message de création réception" />
<MESSAGE value="fix : correction des retours de la V0" />
<MESSAGE value="feat : ajout de l'api de l'état pour chercher les villes via le CP" />
<MESSAGE value="fix : script de déploiement + CI/CD build de l'app" />
<MESSAGE value="fix : order navbar + modification création fournisseur et client" />
<MESSAGE value="fix : order récéption/expédition + correction style bouton récéption" />
<MESSAGE value="fix : style bon de récéption" />
<MESSAGE value="fix : bouton de mise en attente" />
<MESSAGE value="fix : problème de bearer token" />
<MESSAGE value="feat : système de blocage utilisateur" />
<MESSAGE value="feat : ajout d'un système de scanner bovin" />
<MESSAGE value="feat : mise à jour du CLAUDE.md" />
<MESSAGE value="fix : correction du path URI pour la création d'un poids dans une réception" />
<MESSAGE value="feat : Ajout du bundle Monolog pour la gestion des logs" />
<MESSAGE value="fix : affiche plus détail dans les logs en recette/prod" />
<MESSAGE value="fix : modification du script de déploiement pour corriger le problème d'écriture des logs de prod" />
<MESSAGE value="fix : doc de déploiement" />
<MESSAGE value="fix : doc et script de déploiement" />
<MESSAGE value="fix : gitea workflow" />
<MESSAGE value="fix : script de déploiement" />
<MESSAGE value="feat : ajout plus d'information sur la liste des réceptions côté front sur la page d'accueil" />
<MESSAGE value="fix : redirige sur le login sur une 401 et reset du auth state + doc + timeout du toaster" />
<MESSAGE value="feat : ajout de la debug bar en mod dev" />
<MESSAGE value="feat : ajout du bundle Malio ednotif pour l'utilisation des WS" />
<MESSAGE value="fix : modification de la conf du bundle ednotif" />
<MESSAGE value="feat : update du CHANGELOG.md" />
<MESSAGE value="feat : finalisation de l'étape 1 &quot;Réception&quot; (formulaire)" />
<MESSAGE value="feat : ajout du numéro identification des receptions et ajustement du bon de reception" />
<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 : la page de scanner est accessible que pour les admins" />
<MESSAGE value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<option name="LAST_COMMIT_MESSAGE" value="fix : les non-admin ne peuvent plus supprimer de réception/expédition en attente" />
<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)" />
<option name="LAST_COMMIT_MESSAGE" value="feat : Ajout de la sélection des bovins étape 3 d'une réception (WIP)" />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
@@ -805,14 +791,10 @@
<line>6</line>
<option name="timeStamp" value="3" />
</line-breakpoint>
<line-breakpoint enabled="true" type="php">
<url>file://$PROJECT_DIR$/src/Entity/Shipment.php</url>
<line>6</line>
<option name="timeStamp" value="45" />
</line-breakpoint>
<line-breakpoint enabled="true" type="javascript">
<url>file://$PROJECT_DIR$/frontend/services/dto/shipment-data.ts</url>
<option name="timeStamp" value="43" />
<url>file://$PROJECT_DIR$/frontend/services/shipment.ts</url>
<properties lambdaOrdinal="-1" />
<option name="timeStamp" value="37" />
</line-breakpoint>
</breakpoints>
</breakpoint-manager>

59
AGENTS.md Normal file
View File

@@ -0,0 +1,59 @@
# 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.
- 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.
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`.
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).
- 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

@@ -43,29 +43,7 @@ Ajouter dans le fichier .env du frontend
* [#313] Admin modification creation fournisseur
* [#275] Lister les expéditions en attente
* [#276] Lister les expéditions terminées
* [#324] Creation page admin listing clients
* [#326] Admin modification creation client
* [#325] Correction diverses
* fix layout admin
* Creation page admin listing bovins
* Creation page admin ajout/modification bovins
* [#331] Mettre à jour l'entité Shipment et bovin_shipment
* [#278] Plan du site
* [#334] Correctifs
* [#332] Refonte écran réception terminée
* [#327] afficher/modifier écran expédition terminée
* [#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
* [#FER-11] Corriger le problème de bearer token
* [#FER-12] Ajouter un blocage des utilisateurs
* [#FER-13] Faire des recherches sur le scanner des bêtes
* [#FER-15] Les non-admin ne peuvent plus supprimer de réception/expédition en attente
* [#FER-17] Ecran d'ajout de bovin
### Changed
### Fixed

169
CLAUDE.md
View File

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

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.0.83'
app.version: '0.0.41'

View File

@@ -7,17 +7,3 @@
@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,45 +1,35 @@
<template>
<form :class="{ submitted }" @submit.prevent="validateForm">
<div class="flex items-center mb-11 justify-between relative">
<div class="flex flex-row absolute -left-[60px] ">
<Icon @click="goBack" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
<form @submit.prevent="validateForm">
<div class="flex items-center justify-between gap-10">
<div>
<h1 class="text-3xl font-bold uppercase">
{{ props.address ? "Modification d'une adresse" : "Ajout d'une adresse" }}
</h1>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ props.address ? "Modification d'une adresse" : "Ajout d'une adresse" }}
</h1>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
:disabled="isLoading"
>
{{ props.address? "Sauvegarder" : "Ajouter" }}
</button>
</div>
<div class="grid grid-cols-2 gap-y-16 gap-x-[200px] mb-16">
<UiTextInput id="address-street" v-model="form.street" label="Rue" required />
<div class="grid grid-cols-2 gap-y-16 gap-x-12 mb-16 mt-10">
<UiTextInput id="address-label" v-model="form.label" label="Libellé" />
<UiTextInput id="address-street" v-model="form.street" label="Rue" />
<UiTextInput id="address-street2" v-model="form.street2" label="Complément" />
<UiTextInput id="address-postalCode" v-model="form.postalCode" label="Code postal" required />
<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)" />
</div>
<div class="flex justify-center items-center">
<UiButton
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"
:disabled="isLoading"
@click="submitted = true"
>
Valider
</UiButton>
<UiTextInput id="address-postalCode" v-model="form.postalCode" label="Code postal" />
<UiTextInput id="address-city" v-model="form.city" label="Ville" />
<UiTextInput id="address-country" v-model="form.countryCode" label="Pays" />
</div>
</form>
</template>
<script setup lang="ts">
import type { AddressPayload } from "~/services/address"
import { getCommunesByPostalCode, type CommuneData } from "~/services/geo"
import { AddressPayload } from "~/services/address"
const route = useRoute()
const router = useRouter()
@@ -50,45 +40,26 @@ const props = defineProps<{
}>()
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 => ({
label: "",
street: "",
street2: null,
postalCode: "",
city: "",
countryCode: "FR",
countryCode: "",
})
const form = reactive<AddressPayload>(emptyForm())
const backPath = computed(() => {
if (props.type === "customer") {
const customerId = Number(route.query.customerId)
return Number.isFinite(customerId) && customerId > 0
? `/admin/customer/${customerId}`
: "/admin/customer/customer-list"
}
const supplierId = Number(route.query.supplierId)
return Number.isFinite(supplierId) && supplierId > 0
? `/admin/supplier/${supplierId}`
: "/admin/supplier/supplier-list"
})
const hydrateForm = (address?: AddressPayload | null) => {
const data = address ?? emptyForm()
form.label = data.label ?? ""
form.street = data.street ?? ""
form.street2 = data.street2 ?? null
form.postalCode = data.postalCode ?? ""
form.city = data.city ?? ""
form.countryCode = data.countryCode || "FR"
form.countryCode = data.countryCode ?? ""
}
watch(
@@ -99,50 +70,11 @@ watch(
{ 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 = () => {
if (isLoading.value) return
emit("validate", {...form})
}
const goBack = () => {
router.push(backPath.value)
}
const emit = defineEmits<{
(event: 'validate', form: AddressPayload): void
}>()

View File

@@ -2,22 +2,22 @@
<template>
<NuxtLink :to="link">
<div class="w-[300px] h-[216px] border border-primary-700 rounded-lg p-6 flex flex-col justify-between gap-4">
<div class="w-[324px] h-[228px] border border-black rounded-md p-6 flex flex-col justify-between">
<div class="flex justify-between">
<div class="rounded-full w-[80px] h-[80px] bg-[#D9D9D9] flex justify-center items-center">
<Icon :name="iconName" class="!text-primary-700" size="44" />
<div class="rounded-full w-[80px] h-[80px] bg-neutral-400 flex justify-center items-center">
<Icon :name="iconName" style="color: black" size="44" />
</div>
<div>
<Icon name="mdi:plus" style="color: black" size="44" />
</div>
</div>
<div class="uppercase font-bold">
<p class="text-3xl text-primary-700">
<slot name="label">{{ label }}</slot>
</p>
<p class="text-3xl"> {{ label }} </p>
</div>
</div>
</NuxtLink>
</template>
<script setup lang="ts">
@@ -27,3 +27,4 @@ const props = defineProps<{
label: string
}>()
</script>

View File

@@ -1,65 +0,0 @@
<template>
<form>
<div class="grid grid-cols-3 gap-x-40 gap-y-8 mb-8">
<UiNumberInput
:key="localWeight.type"
:label="'POIDS'"
labelClass="font-bold uppercase text-xl "
v-model="localWeight.weight"
:disabled="!isAdmin"
:min="0"
wrapper-class="flex-col"
required
/>
<UiDateInput
label="Date de pesée"
v-model="localWeight.weighedAt"
:disabled="!isAdmin"
required
/>
<UiNumberInput
label="Dsd"
class="col-start-2"
labelClass="font-bold uppercase"
v-model="localWeight.dsd"
:disabled="!isAdmin"
wrapper-class="flex-col"
required
/>
</div>
</form>
</template>
<script setup lang="ts">
import type {WeightEntryData} from '~/services/dto/weight-data'
import {reactive, watch} from "vue";
const props = defineProps<{
modelValue: WeightEntryData
isAdmin: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: WeightEntryData): void
}>()
const localWeight = reactive<WeightEntryData>({...props.modelValue})
watch(
() => props.modelValue,
(value) => {
Object.assign(localWeight, value)
},
{deep: true}
)
watch(
localWeight,
(value) => {
emit('update:modelValue', {...value})
},
{deep: true}
)
</script>

View File

@@ -1,26 +1,21 @@
<template>
<form
<div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"
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>
class="flex flex-col items-center gap-16">
<h1 class="text-4xl uppercase font-bold">Sélection des marchandises réceptionnnées</h1>
<div
class="flex flex-row gap-8 items-center w-full">
class="flex flex-row gap-8 items-center">
<div
v-for="type in bovineType"
:key="type.id"
class="mt-8 flex flex-row mb-2 w-full">
class="mt-8 flex flex-row mb-2 gap-6">
<UiNumberInput
:id="type.id"
:label="type.label"
:code="type.code"
v-model="bovineQuantities[String(type.id)]"
:placeholder="0"
:min="0"
:max="10"
class="max-w-[150px]"
wrapper-class="gap-3"
/>
</div>
<div
@@ -28,22 +23,15 @@
<UiNumberInput
label="Autres"
v-model="otherQuantity"
class="max-w-[80px]"
wrapper-class="gap-3"
/>
</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">
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Valider
</UiButton>
</div>
</form>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser
</button>
</div>
</template>
<script setup lang="ts">
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
@@ -62,7 +50,6 @@ const toast = useToast()
const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([])
const receptionStore = useReceptionStore()
const showBovineError = ref(false)
const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0)
const receptionId = computed(() => receptionStore.current?.id ?? null)
@@ -90,14 +77,14 @@ onMounted(async () => {
})
watch(
[() => receptionId.value, () => bovineType.value],
async ([id, types]) => {
if (!id || !receptionIri.value || types.length === 0) {
() => receptionId.value,
async (id) => {
if (!id || !receptionIri.value) {
return
}
const selectionMap: Record<string, number | null> = {}
for (const type of types) {
for (const type of bovineType.value) {
selectionMap[String(type.id)] = 0
}
@@ -174,13 +161,7 @@ async function goNext() {
return
}
showBovineError.value = false
if (totalBovines.value === 0) {
showBovineError.value = true
return
}
// @TODO Ajouter un composable pour le toaster qui gère les key i18n
if (totalBovines.value > 52) {
toast.error({
title: 'Erreur',

View File

@@ -1,7 +1,8 @@
<template>
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
<form @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Réception</h1>
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Réception</h1>
<!-- Nom de l'utilisateur -->
<UiSelect
id="reception-user"
v-model="form.userId"
@@ -12,15 +13,15 @@
}))"
:loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2"
required
/>
<!-- Date de réception -->
<UiDateInput
id="reception-date"
v-model="form.receptionDate"
label="Date de réception"
wrapper-class="col-start-1 row-start-3"
required
/>
<!-- Type de réception -->
<UiSelect
id="reception-type"
v-model="form.receptionTypeId"
@@ -30,8 +31,8 @@
label: type.label
}))"
wrapper-class="col-start-1 row-start-4"
required
/>
<!-- Fournisseur -->
<UiSelect
id="reception-supplier"
v-model="form.supplierId"
@@ -42,17 +43,20 @@
}))"
:loading="isLoadingSuppliers"
wrapper-class="col-start-1 row-start-5"
required
/>
<!-- Adresse fournisseur -->
<UiSelect
id="reception-address"
v-model="form.addressId"
label="Adresse"
:options="addressOptions"
:disabled="isLoadingSuppliers || ownerAddresses.length === 0"
:options="supplierAddresses.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))"
:disabled="isLoadingSuppliers || supplierAddresses.length === 0"
wrapper-class="col-start-2 row-start-1"
required
/>
<!-- Camion -->
<UiSelect
id="reception-truck"
v-model="form.truckId"
@@ -63,8 +67,8 @@
}))"
:loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2"
required
/>
<!-- Transporteur -->
<UiSelect
id="reception-carrier"
v-model="form.carrierId"
@@ -76,15 +80,27 @@
:loading="isLoadingCarriers"
select-class="h-[34px]"
wrapper-class="col-start-2 row-start-3"
required
/>
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<!-- Chauffeur (LIOT) -->
<UiSelect
id="reception-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="col-start-2 row-start-4"
/>
<!-- Plaque d'immatriculation -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-5">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
required
/>
</div>
<!-- Immatriculation (LIOT) -->
<UiSelect
v-if="isLiotCarrier"
id="reception-vehicle"
@@ -96,53 +112,43 @@
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4 h-[64px]"
required
/>
<UiSelect
id="reception-driver"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
v-if="isLiotCarrier"
wrapper-class="col-start-2 row-start-5"
required
/>
</div>
<div class="flex justify-center">
<UiButton
<button
type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
>Valider
</UiButton>
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Peser
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { useReceptionStore } from '~/stores/reception'
import { useFormDataLoading } from '~/composables/useFormDataLoading'
import { useLiotHandling } from '~/composables/useLiotHandling'
import { useAddressSync } from '~/composables/useAddressSync'
import type { ReceptionTypeData } from '~/services/dto/reception-type-data'
import { getReceptionTypeList } from '~/services/reception-type'
import type { SupplierData } from '~/services/dto/supplier-data'
import { getSupplierList } from '~/services/supplier'
import { RECEPTION_TYPE_CODES } from '~/utils/constants'
import { deleteReceptionBovine, getReceptionBovineList } from '~/services/reception-bovine'
import type { ReceptionFormData } from '~/services/dto/reception-data'
import {useReceptionStore} from '~/stores/reception'
import type {ReceptionTypeData} from '~/services/dto/reception-type-data'
import {getReceptionTypeList} from '~/services/reception-type'
import type {UserData} from '~/services/dto/user-data'
import {getUsers} from '~/services/auth'
import {useAuthStore} from '~/stores/auth'
import type {SupplierData} from '~/services/dto/supplier-data'
import {getSupplierList} from '~/services/supplier'
import type {TruckData} from '~/services/dto/truck-data'
import {getTruckList} from '~/services/truck'
import type {CarrierData} from '~/services/dto/carrier-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 receptionStore = useReceptionStore()
const isHydrating = ref(false)
const submitted = ref(false)
const formRef = ref<HTMLFormElement | null>(null)
const form = reactive<ReceptionFormData>({
licensePlate: '',
receptionDate: new Date().toISOString().slice(0, 10),
@@ -155,27 +161,62 @@ const form = reactive<ReceptionFormData>({
driverId: '',
vehicleId: ''
})
const allowAnyLicensePlate = ref(false)
const receptionTypes = ref<ReceptionTypeData[]>([])
const users = ref<UserData[]>([])
const isLoadingUsers = ref(false)
const suppliers = ref<SupplierData[]>([])
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)
const { users, trucks, carriers, isLoadingUsers, isLoadingTrucks, isLoadingCarriers, loadCommonData } =
useFormDataLoading(form)
const {
isLiotCarrier, filteredDrivers, filteredVehicles,
isLoadingDrivers, isLoadingVehicles, allowAnyLicensePlate,
loadDrivers, loadVehicles
} = useLiotHandling(form, carriers, isHydrating)
const supplierIdRef = computed(() => form.supplierId)
const { ownerAddresses, addressOptions } = useAddressSync(form, supplierIdRef, suppliers)
// Transporteur sélectionné dans le formulaire
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
// Indique si le transporteur est LIOT
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
// Adresses disponibles pour le fournisseur sélectionné
const supplierAddresses = computed(() => {
const supplierId = Number(form.supplierId)
if (!Number.isFinite(supplierId)) {
return []
}
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(() =>
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 existing = await getReceptionBovineList(receptionIri)
for (const selection of existing) {
@@ -183,6 +224,50 @@ 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 () => {
isLoadingSuppliers.value = true
try {
@@ -192,33 +277,182 @@ const loadSuppliers = async () => {
}
}
watch(
() => receptionStore.current,
(reception) => {
isHydrating.value = true
form.licensePlate = reception?.licensePlate ?? ''
form.receptionDate = reception?.receptionDate?.slice(0, 10) ?? 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 camions pour le select
const loadTrucks = async () => {
isLoadingTrucks.value = true
try {
trucks.value = await getTruckList()
} finally {
isLoadingTrucks.value = false
}
}
// 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 () => {
receptionTypes.value = await getReceptionTypeList()
await loadUsers()
await loadSuppliers()
await loadCommonData()
await loadTrucks()
await loadCarriers()
await loadDrivers()
await loadVehicles()
await authStore.ensureSession()
setDefaultUser()
})
const buildPayload = () => {
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.supplierId, 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) {
form.addressId = ''
}
},
{immediate: true}
)
// Valide/auto-sélectionne le véhicule selon camion + transporteur (LIOT)
watch(
() => form.carrierId,
() => {
if (isHydrating.value) {
return
}
if (!form.carrierId) {
form.driverId = ''
form.vehicleId = ''
return
}
if (!isLiotCarrier.value) {
form.driverId = ''
form.vehicleId = ''
return
}
if (filteredDrivers.value.length === 1) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
},
{immediate: true}
)
// Récupère la plaque depuis le véhicule choisi (LIOT)
watch(
() => [form.truckId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (filteredVehicles.value.length === 1) {
form.vehicleId = String(filteredVehicles.value[0].id)
return
}
if (!form.vehicleId) {
return
}
const matches = filteredVehicles.value.some(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (!matches) {
form.vehicleId = ''
}
},
{immediate: true}
)
// Auto-renseigne le véhicule si la plaque correspond (LIOT)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value) {
return
}
if (isHydrating.value) {
return
}
const selected = filteredVehicles.value.find(
(vehicle) => String(vehicle.id) === form.vehicleId
)
if (selected) {
form.licensePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licensePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licensePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
// Valide le formulaire et crée/met à jour la réception
async function validate() {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedReceptionDate = form.receptionDate.trim()
const normalizedReceptionTypeId = form.receptionTypeId.trim()
@@ -228,16 +462,29 @@ const buildPayload = () => {
const normalizedTruckId = form.truckId.trim()
const normalizedCarrierId = form.carrierId.trim()
const normalizedDriverId = form.driverId.trim()
const receptionTypeIri = normalizedReceptionTypeId
? `/api/reception_types/${normalizedReceptionTypeId}`
: null
const userIri = normalizedUserId
? `/api/users/${normalizedUserId}`
: null
const supplierIri = normalizedSupplierId
? `/api/suppliers/${normalizedSupplierId}`
: null
const addressIri = normalizedAddressId
? `/api/addresses/${normalizedAddressId}`
: null
const truckIri = normalizedTruckId
? `/api/trucks/${normalizedTruckId}`
: null
const carrierIri = normalizedCarrierId
? `/api/carriers/${normalizedCarrierId}`
: null
const driverIri = normalizedDriverId
? `/api/drivers/${normalizedDriverId}`
: null
const receptionTypeIri = normalizedReceptionTypeId ? `/api/reception_types/${normalizedReceptionTypeId}` : null
const userIri = normalizedUserId ? `/api/users/${normalizedUserId}` : null
const supplierIri = normalizedSupplierId ? `/api/suppliers/${normalizedSupplierId}` : null
const addressIri = normalizedAddressId ? `/api/addresses/${normalizedAddressId}` : null
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const carrierIri = normalizedCarrierId ? `/api/carriers/${normalizedCarrierId}` : null
const driverIri = normalizedDriverId ? `/api/drivers/${normalizedDriverId}` : null
return {
const basePayload = {
licensePlate: normalizedLicensePlate,
receptionDate: normalizedReceptionDate,
receptionType: receptionTypeIri,
@@ -245,35 +492,13 @@ const buildPayload = () => {
supplier: supplierIri,
address: addressIri,
truck: truckIri,
carrier: carrierIri,
...(isLiotCarrier.value && driverIri ? { driver: driverIri } : {})
carrier: carrierIri
}
}
const saveDraft = async () => {
const payload = buildPayload()
if (!receptionStore.current) {
await receptionStore.createReception({
currentStep: 0,
...payload
})
return
const payload = {
...basePayload,
...(isLiotCarrier.value && driverIri ? {driver: driverIri} : {})
}
await receptionStore.updateReception(receptionStore.current.id, {
currentStep: receptionStore.current.currentStep,
...payload
})
}
const validateFields = () => {
submitted.value = true
return formRef.value?.reportValidity() ?? false
}
defineExpose({ saveDraft, validateFields })
async function validate() {
const payload = buildPayload()
if (!receptionStore.current) {
const created = await receptionStore.createReception({
@@ -302,4 +527,5 @@ async function validate() {
...payload
})
}
</script>

View File

@@ -1,16 +1,15 @@
<template>
<form :class="['flex flex-col items-center gap-16', { submitted }]" @submit.prevent="goNext">
<div class="flex flex-col items-center gap-16">
<div
v-if="receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"
class="flex flex-col gap-16 items-center w-full">
<h1 class="text-4xl uppercase font-bold text-primary-500">Sélection des marchandises réceptionnnées</h1>
<h1 class="text-4xl uppercase font-bold">Sélection des marchandises réceptionnnées</h1>
<UiSelect
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
label="Type de marchandises"
:options="merchandiseTypes.map((type) => ({ value: String(type.id), label: type.label }))"
wrapper-class="w-[550px]"
required
/>
<div
v-if="selectedMerchandiseTypeId && isAutres"
@@ -22,30 +21,24 @@
label="Préciser"
placeholder="Précisions complémentaires"
:maxlength="255"
required
/>
</div>
<div
v-if="selectedMerchandiseTypeId && !isGranule"
class="flex flex-col gap-4 w-[550px]"
class="flex gap-4 w-[550px] justify-evenly"
>
<div class="flex gap-4 justify-between">
<div
v-for="building in buildings"
:key="building.id"
>
<UiCheckbox
v-model="selectedBuildingIds"
:value="String(building.id)"
:label="building.label"
label-class="text-xl"
/>
</div>
<div
v-for="building in buildings"
:key="building.id"
>
<UiCheckbox
v-model="selectedBuildingIds"
:value="String(building.id)"
:label="building.label"
label-class="text-xl"
/>
</div>
<p class="text-red-500 text-sm" :class="showBuildingError ? '' : 'invisible'">
Veuillez sélectionner au moins un bâtiment.
</p>
</div>
<div
@@ -54,35 +47,29 @@
>
<div class="grid grid-cols-1 gap-10 md:grid-cols-4">
<div v-for="type in pelletTypes" :key="type.id" class="flex flex-col gap-4">
<p class="font-bold uppercase text-primary-500">{{ type.label }}</p>
<p class="font-bold uppercase">{{ type.label }}</p>
<div
v-for="building in buildings"
:key="building.id"
class="flex items-center gap-2 text-lg pl-[2px]"
class="flex items-center gap-2 text-lg"
>
<UiCheckbox
v-model="selectedPelletBuildingIds[String(type.id)]"
:value="String(building.id)"
:label="building.label"
label-class="text-xl"
label-class="text-lg"
/>
</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 class="flex justify-center">
<UiButton
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
>Valider
</UiButton>
</div>
</form>
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="goNext"
>Peser
</button>
</div>
</template>
<script setup lang="ts">
@@ -110,9 +97,6 @@ const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
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.
const getRelationId = (value: unknown): string | null => {
@@ -195,23 +179,6 @@ async function goNext() {
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 receptionIri = `/api/receptions/${receptionStore.current.id}`

View File

@@ -0,0 +1,101 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase">{{ 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-black h-[90px] mt-12 mb-[25px] text-4xl">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesee' : 'peser' }}</button>
<button
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</button>
<button
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</button>
</div>
</template>
<script setup lang="ts">
import { computed } 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 (false === displayWeight.value) {
fetchWeight()
}
})
</script>

View File

@@ -1,161 +1,183 @@
<template>
<form>
<div class="flex flex-row justify-between gap-x-12 font-bold uppercase mb-8">
<form @submit.prevent="validate">
<div
class="flex flex-col items-center gap-16">
<div
class="flex flex-row gap-6 items-center">
<div
v-for="type in bovineTypes"
v-for="type in bovineType"
:key="type.id"
>
class="flex flex-row mb-2 gap-6 ">
<UiNumberInput
:label="type.label"
:code="type.code"
v-model="localQuantities[String(type.id)]"
:disabled="!isAdmin"
v-model="bovineQuantities[String(type.id)]"
:disabled="!auth.isAdmin"
:placeholder="0"
:min="0"
:max="10"
wrapperClass="w-44 flex-col"
inputClass="font-medium"
/>
</div>
<UiNumberInput
label="Autres"
v-model="localOtherQuantity"
:disabled="!isAdmin"
wrapperClass="w-44 flex-col"
inputClass="font-medium"
/>
<div
class=" flex flex-row mb-2 gap-6">
<UiNumberInput
label="Autres"
v-model="otherQuantity"
:disabled="!auth.isAdmin"
/>
</div>
</div>
<button
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from 'vue'
import { getBovineTypeList } from '~/services/bovine-type'
import type { BovineTypeData } from '~/services/dto/bovine-type-data'
import type { ReceptionBovineTypeData } from '~/services/dto/reception-bovine-data'
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
import {getBovineTypeList} from "~/services/bovine-type";
import {
createReceptionBovine,
deleteReceptionBovine,
getReceptionBovineList,
updateReceptionBovine
} from "~/services/reception-bovine";
import {computed, onMounted, reactive, ref, watch} from "vue";
import {getReception, updateReception} from "~/services/reception";
const toast = useToast()
const isLoadingBovineType = ref(false)
const bovineType = ref<BovineTypeData[]>([])
const bovineQuantities = reactive<Record<string, number | null>>({})
const otherQuantity = ref<number | null>(0)
const auth = useAuthStore()
const props = defineProps<{
modelValue: ReceptionBovineTypeData[]
otherQuantity: number | null
isAdmin: boolean
idReception: number
}>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const emit = defineEmits<{
(event: 'update:modelValue', value: ReceptionBovineTypeData[]): void
(event: 'update:otherQuantity', value: number | null): void
}>()
const receptionIri = computed(() =>
receptionId ? `/api/receptions/${receptionId}` : null
)
const totalBovines = computed(() => {
const base = Object.values(bovineQuantities).reduce((sum, value) => {
return sum + (value ?? 0)
}, 0)
return base + (otherQuantity.value ?? 0)
})
const bovineTypes = ref<BovineTypeData[]>([])
const localQuantities = reactive<Record<string, number | null>>({})
const localOtherQuantity = ref<number | null>(props.otherQuantity ?? 0)
// Verrou pour éviter les boucles props -> local -> emit -> props.
const isSyncing = ref(false)
function entriesEqualByTypeAndQuantity(
left: ReceptionBovineTypeData[],
right: ReceptionBovineTypeData[]
): boolean {
const toMap = (entries: ReceptionBovineTypeData[]) => {
const map = new Map<number, number>()
for (const entry of entries) {
const typeId = entry.bovineType?.id ?? 0
map.set(typeId, entry.quantity ?? 0)
}
return map
}
const a = toMap(left)
const b = toMap(right)
if (a.size !== b.size) {
return false
}
for (const [typeId, quantity] of a.entries()) {
if ((b.get(typeId) ?? 0) !== quantity) {
return false
}
}
return true
}
function buildEntriesFromLocal(): ReceptionBovineTypeData[] {
return bovineTypes.value.map((type) => {
const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id)
return {
id: existing?.id ?? 0,
bovineType: type,
quantity: localQuantities[String(type.id)] ?? 0
}
})
}
function syncLocalFromProps() {
isSyncing.value = true
const loadBovineType = async () => {
isLoadingBovineType.value = true
try {
for (const key of Object.keys(localQuantities)) {
delete localQuantities[key]
}
for (const type of bovineTypes.value) {
const existing = props.modelValue.find((entry) => entry.bovineType.id === type.id)
localQuantities[String(type.id)] = existing?.quantity ?? 0
}
bovineType.value = await getBovineTypeList()
} finally {
isSyncing.value = false
isLoadingBovineType.value = false
}
}
onMounted(async () => {
await loadBovineType()
})
watch(
() => props.otherQuantity,
(value) => {
if (isSyncing.value) {
() => receptionId,
async (id) => {
if (!id || !receptionIri.value) {
return
}
const next = value ?? 0
isSyncing.value = true
localOtherQuantity.value = next
isSyncing.value = false
}
const selectionMap: Record<string, number | null> = {}
for (const type of bovineType.value) {
selectionMap[String(type.id)] = 0
}
const existing = await getReceptionBovineList(receptionIri.value)
for (const selection of existing) {
const bovineTypeId = String(selection.bovineType.id)
selectionMap[bovineTypeId] = selection.quantity ?? 0
}
for (const key of Object.keys(bovineQuantities)) {
delete bovineQuantities[key]
}
Object.assign(bovineQuantities, selectionMap)
const existingOther = await reception.bovineDetail
const parsedOther =
typeof existingOther === 'string' && existingOther.trim() !== ''
? Number(existingOther)
: 0
otherQuantity.value = Number.isFinite(parsedOther) ? parsedOther : 0
},
{immediate: true}
)
watch(localOtherQuantity, (value) => {
if (isSyncing.value) {
async function syncBovineSelections(receptionIri: string) {
const existing = await getReceptionBovineList(receptionIri)
const existingMap = new Map<string, { id: number; quantity: number | null }>()
for (const selection of existing) {
const bovineTypeId = String(selection.bovineType.id)
existingMap.set(bovineTypeId, {
id: selection.id,
quantity: selection.quantity ?? 0
})
}
// Supprime les entrées supprimées ou modifiées
for (const [bovineTypeId, entry] of existingMap.entries()) {
const selectedQuantity = bovineQuantities[bovineTypeId] ?? 0
if (!selectedQuantity) {
await deleteReceptionBovine(entry.id)
existingMap.delete(bovineTypeId)
continue
}
if (selectedQuantity !== entry.quantity) {
await updateReceptionBovine(entry.id, {quantity: selectedQuantity})
existingMap.set(bovineTypeId, {
id: entry.id,
quantity: selectedQuantity
})
}
}
// Crée les entrées manquantes
for (const [bovineTypeId, quantity] of Object.entries(bovineQuantities)) {
if (!quantity) {
continue
}
if (existingMap.has(bovineTypeId)) {
// Déjà à jour
continue
}
await createReceptionBovine({
reception: receptionIri,
bovineType: `/api/bovine_types/${bovineTypeId}`,
quantity
})
}
}
async function validate() {
// @TODO Ajouter un composable pour le toaster qui gère les key i18n
if (totalBovines.value > 52) {
toast.error({
title: 'Erreur',
message: ('Le total des bovins ne peut pas dépasser 52.')
})
return
}
const next = value ?? 0
emit('update:otherQuantity', next)
})
await syncBovineSelections(receptionIri.value)
watch(
() => props.modelValue,
() => {
// Hydratation locale uniquement quand le parent change.
syncLocalFromProps()
},
{ immediate: true }
)
watch(
localQuantities,
() => {
if (isSyncing.value) {
return
}
// N'émet que si les quantités diffèrent réellement du parent.
const nextEntries = buildEntriesFromLocal()
if (!entriesEqualByTypeAndQuantity(nextEntries, props.modelValue)) {
emit('update:modelValue', nextEntries)
}
},
{ deep: true }
)
onMounted(async () => {
bovineTypes.value = await getBovineTypeList()
syncLocalFromProps()
})
await updateReception(receptionId, {
merchandiseType: null,
merchandiseDetail: null,
bovineDetail: otherQuantity.value ? String(otherQuantity.value) : null,
})
}
</script>

View File

@@ -1,72 +1,65 @@
<template>
<form>
<div class="flex flex-col">
<div class="w-full relative grid grid-cols-[1fr_200px]">
<UiRadioGroup
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
label="Type de marchandises"
:options="merchandiseTypes.map((type) => ({
value: String(type.id),
label: type.label
}))"
input-class="accent-primary-700 focus:ring-primary-700"
option-label-class="uppercase"
wrapper-class="w-full uppercase"
group-class="grid grid-cols-4 mt-9 mb-7"
:disabled="!isAdmin"
/>
<form @submit.prevent="validate">
<div class="flex flex-col items-center gap-16">
<div
class="flex flex-col gap-16 items-center w-full">
<UiTextInput
id="merchandise-type"
v-model="selectedMerchandiseTypeId"
label="Type de marchandises"
:value="reception.merchandiseType?.label"
wrapper-class="w-[550px]"
:disabled="true"
/>
<div
v-if="merchandiseTypeId && isAutres"
class="flex flex-col w-full max-w-[550px]"
>
<UiTextInput
v-if="isAutres"
id="merchandise-detail"
:disabled="!isAdmin"
:disabled="!auth.isAdmin"
v-model="merchandiseDetail"
placeholder="Préciser"
label="Préciser"
placeholder="Précisions complémentaires"
:maxlength="255"
wrapper-class="w-[200px] mt-12 mb-7"
/>
</div>
<div
v-if="selectedMerchandiseTypeId && !isGranule"
class="w-full grid grid-cols-[1fr_200px]"
v-if="merchandiseTypeId && !isGranule"
class="flex gap-4 w-[550px] justify-evenly"
>
<div class="grid grid-cols-4 gap-6"
<div
v-for="building in buildings"
:key="building.id"
>
<div
v-for="building in buildings"
:key="building.id"
>
<UiCheckbox
v-model="selectedBuildingIds"
:value="String(building.id)"
:label="building.label"
:disabled="!isAdmin"
input-class="accent-primary-700 focus:ring-primary-700"
label-class="uppercase"
/>
</div>
<UiCheckbox
v-model="selectedBuildingIds"
:value="String(building.id)"
:label="building.label"
:disabled="!auth.isAdmin"
label-class="text-xl"
/>
</div>
</div>
<div
v-if="selectedMerchandiseTypeId && isGranule"
class="grid grid-cols-[1fr_200px] w-full col-start-2 row-start-1"
v-if="merchandiseTypeId && isGranule"
class="flex flex-col gap-10 w-full max-w-[1100px]"
>
<div class="grid grid-cols-4 gap-6 justify-between">
<div class="grid grid-cols-1 gap-10 md:grid-cols-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="font-bold uppercase">{{ type.label }}</p>
<div
v-for="building in buildings"
:key="building.id"
class="flex text-lg"
class="flex items-center gap-2 text-lg"
>
<UiCheckbox
v-model="selectedPelletBuildingIds[String(type.id)]"
:value="String(building.id)"
:label="building.label"
:disabled="!isAdmin"
input-class="accent-primary-700 focus:ring-primary-700"
:disabled="!auth.isAdmin"
label-class="text-lg"
/>
</div>
@@ -74,181 +67,81 @@
</div>
</div>
</div>
<button
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>Valider
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { BuildingData } from '~/services/dto/building-data'
import type { MerchandiseTypeData } from '~/services/dto/merchandise-type-data'
import type { PelletTypeData } from '~/services/dto/pellet-type-data'
import type { MerchandiseEntryData } from '~/services/dto/reception-data'
import { getBuildingList } from '~/services/building'
import { getMerchandiseTypeList } from '~/services/merchandise-type'
import { getPelletTypeList } from '~/services/pellet-type'
import { MERCHANDISE_TYPE_CODES } from '~/utils/constants'
const props = defineProps<{
modelValue: MerchandiseEntryData
isAdmin: boolean
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: MerchandiseEntryData): void
}>()
import {computed, onMounted, ref} from 'vue'
import {getBuildingList} from '~/services/building'
import {getMerchandiseTypeList} from '~/services/merchandise-type'
import type {MerchandiseTypeData} from '~/services/dto/merchandise-type-data'
import type {BuildingData} from '~/services/dto/building-data'
import type {PelletTypeData} from '~/services/dto/pellet-type-data'
import {getPelletTypeList} from '~/services/pellet-type'
import {
createReceptionPelletBuilding,
deleteReceptionPelletBuilding,
getReceptionPelletBuildingList
} from '~/services/reception-pellet-building'
import {MERCHANDISE_TYPE_CODES} from '~/utils/constants'
import {getReception, updateReception} from "~/services/reception";
const merchandiseTypes = ref<MerchandiseTypeData[]>([])
const buildings = ref<BuildingData[]>([])
const pelletTypes = ref<PelletTypeData[]>([])
const selectedMerchandiseTypeId = ref('')
const selectedBuildingIds = ref<string[]>([])
const selectedPelletBuildingIds = ref<Record<string, string[]>>({})
const merchandiseDetail = ref('')
// Verrou de synchro pour empêcher les aller-retours infinis entre parent et composant.
const isSyncing = ref(false)
const isReady = ref(false)
const auth = useAuthStore()
const props = defineProps<{
idReception: number
}>()
const receptionId = props.idReception
const reception = await getReception(receptionId)
const merchandiseTypeId = await reception.receptionType?.id
// Extrait l'ID d'une relation depuis un IRI ou un objet complet.
const getRelationId = (value: unknown): string | null => {
if (!value) {
return null
}
if (typeof value === 'string') {
const match = value.match(/\/(\d+)$/)
return match ? match[1] : null
}
if (typeof value === 'object' && 'id' in value) {
const record = value as { id?: number | string }
if (typeof record.id === 'number') {
return String(record.id)
}
if (typeof record.id === 'string') {
return record.id
}
}
return null
}
// Type de marchandise sélectionné dans le select
const selectedMerchandiseType = computed(() =>
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value) ?? null
)
const isGranule = computed(
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE
)
const isAutres = computed(
() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES
)
function clonePelletSelections(value: Record<string, string[]>) {
const clone: Record<string, string[]> = {}
for (const [key, buildingIds] of Object.entries(value)) {
clone[key] = [...buildingIds]
}
return clone
}
function sorted(values: string[]): string[] {
return [...values].sort()
}
function normalizeModel(value: MerchandiseEntryData): MerchandiseEntryData {
// Normalisation stable pour comparer deux modèles sans faux positifs (ordre des tableaux).
const pellet: Record<string, string[]> = {}
const pelletKeys = Object.keys(value.selectedPelletBuildingIds ?? {}).sort()
for (const key of pelletKeys) {
pellet[key] = sorted(value.selectedPelletBuildingIds[key] ?? [])
}
return {
merchandiseTypeId: value.merchandiseTypeId ?? '',
merchandiseDetail: value.merchandiseDetail ?? '',
selectedBuildingIds: sorted(value.selectedBuildingIds ?? []),
selectedPelletBuildingIds: pellet
}
}
function buildCurrentModel(): MerchandiseEntryData {
return {
merchandiseTypeId: selectedMerchandiseTypeId.value,
merchandiseDetail: merchandiseDetail.value,
selectedBuildingIds: [...selectedBuildingIds.value],
selectedPelletBuildingIds: clonePelletSelections(selectedPelletBuildingIds.value)
}
}
function isSameModel(left: MerchandiseEntryData, right: MerchandiseEntryData): boolean {
return JSON.stringify(normalizeModel(left)) === JSON.stringify(normalizeModel(right))
}
function ensurePelletKeys() {
for (const pelletType of pelletTypes.value) {
const key = String(pelletType.id)
if (!selectedPelletBuildingIds.value[key]) {
selectedPelletBuildingIds.value[key] = []
}
}
}
function hydrateFromModelValue(value: MerchandiseEntryData) {
isSyncing.value = true
try {
selectedMerchandiseTypeId.value = value.merchandiseTypeId ?? ''
merchandiseDetail.value = value.merchandiseDetail ?? ''
selectedBuildingIds.value = [...(value.selectedBuildingIds ?? [])]
selectedPelletBuildingIds.value = clonePelletSelections(
value.selectedPelletBuildingIds ?? {}
)
ensurePelletKeys()
} finally {
isSyncing.value = false
}
}
function sanitizeLocalState() {
if (isGranule.value) {
if (selectedBuildingIds.value.length > 0) {
selectedBuildingIds.value = []
}
} else {
for (const key of Object.keys(selectedPelletBuildingIds.value)) {
if (selectedPelletBuildingIds.value[key].length > 0) {
selectedPelletBuildingIds.value[key] = []
}
}
}
if (!isAutres.value && merchandiseDetail.value !== '') {
merchandiseDetail.value = ''
}
}
function emitCurrentModel() {
const currentModel = buildCurrentModel()
// Ne pas réémettre si rien n'a changé côté métier.
if (isSameModel(currentModel, props.modelValue)) {
return
}
emit('update:modelValue', currentModel)
}
watch(
() => props.modelValue,
(value) => {
const currentModel = buildCurrentModel()
// Si local == parent, on ignore pour éviter la boucle de réhydratation.
if (isSameModel(currentModel, value)) {
return
}
hydrateFromModelValue(value)
},
{ immediate: true }
)
watch(
[selectedMerchandiseTypeId, selectedBuildingIds, selectedPelletBuildingIds, merchandiseDetail],
() => {
if (isSyncing.value || !isReady.value) {
return
}
const beforeSanitize = buildCurrentModel()
isSyncing.value = true
// Applique les règles métier (granulé / autres) avant émission.
sanitizeLocalState()
isSyncing.value = false
const afterSanitize = buildCurrentModel()
// Si la sanitation a modifié l'état, on laisse le watcher repasser proprement.
if (!isSameModel(beforeSanitize, afterSanitize)) {
return
}
emitCurrentModel()
},
{ deep: true }
merchandiseTypes.value.find((type) => String(type.id) === selectedMerchandiseTypeId.value)
)
// Indique si le type est "Granulé"
const isGranule = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.GRANULE)
// Indique si le type est "Autres"
const isAutres = computed(() => selectedMerchandiseType.value?.code === MERCHANDISE_TYPE_CODES.AUTRES)
// Charge les référentiels et hydrate le formulaire depuis la réception
onMounted(async () => {
const [merchandiseTypeList, buildingList, pelletTypeList] = await Promise.all([
getMerchandiseTypeList(),
@@ -259,7 +152,106 @@ onMounted(async () => {
buildings.value = buildingList
pelletTypes.value = pelletTypeList
hydrateFromModelValue(props.modelValue)
isReady.value = true
const currentId = reception.merchandiseType?.id
if (currentId) {
selectedMerchandiseTypeId.value = String(currentId)
}
merchandiseDetail.value = reception.merchandiseDetail ?? ''
selectedBuildingIds.value =
reception.buildings?.map((building) => String(building.id)) ?? []
const existingPelletSelections = reception.pelletBuildings ?? []
const selectionMap: Record<string, string[]> = {}
for (const selection of existingPelletSelections) {
// L'API peut renvoyer les relations comme IRI ou comme objets selon le contexte.
const pelletTypeId = getRelationId(selection.pelletType)
const buildingId = getRelationId(selection.building)
if (!pelletTypeId || !buildingId) {
continue
}
if (!selectionMap[pelletTypeId]) {
selectionMap[pelletTypeId] = []
}
selectionMap[pelletTypeId].push(buildingId)
}
for (const pelletType of pelletTypes.value) {
const key = String(pelletType.id)
if (!selectionMap[key]) {
selectionMap[key] = []
}
}
selectedPelletBuildingIds.value = selectionMap
})
// Enregistre les sélections et passe à l'étape suivante
async function validate() {
const receptionIri = `/api/receptions/${reception.id}`
await updateReception(reception.id, {
merchandiseDetail: isAutres.value ? merchandiseDetail.value.trim() : null,
buildings: isGranule.value
? []
: selectedBuildingIds.value.map((id) => `/api/buildings/${id}`),
bovineDetail: null,
bovinesTypes: null,
})
if (isGranule.value) {
await syncPelletSelections(receptionIri)
} else {
await clearPelletSelections(receptionIri)
}
}
// Supprime toutes les associations granulés/bâtiments existantes
async function clearPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
for (const selection of existing) {
await deleteReceptionPelletBuilding(selection.id)
}
}
// Synchronise les associations granulés/bâtiments avec l'état du formulaire
async function syncPelletSelections(receptionIri: string) {
const existing = await getReceptionPelletBuildingList(receptionIri)
const existingMap = new Map<string, number>()
for (const selection of existing) {
// Construit la table de correspondance avec des IDs normalisés pour éviter les doublons.
const pelletTypeId = getRelationId(selection.pelletType)
const buildingId = getRelationId(selection.building)
if (!pelletTypeId || !buildingId) {
continue
}
const key = `${pelletTypeId}:${buildingId}`
existingMap.set(key, selection.id)
}
const desiredEntries: Array<{ pelletTypeId: string; buildingId: string }> = []
for (const [pelletTypeId, buildingIds] of Object.entries(selectedPelletBuildingIds.value)) {
for (const buildingId of buildingIds) {
desiredEntries.push({pelletTypeId, buildingId})
}
}
const desiredKeys = new Set(desiredEntries.map(
(entry) => `${entry.pelletTypeId}:${entry.buildingId}`
))
for (const [key, id] of existingMap.entries()) {
if (!desiredKeys.has(key)) {
await deleteReceptionPelletBuilding(id)
}
}
for (const entry of desiredEntries) {
const key = `${entry.pelletTypeId}:${entry.buildingId}`
if (!existingMap.has(key)) {
await createReceptionPelletBuilding({
reception: receptionIri,
pelletType: `/api/pellet_types/${entry.pelletTypeId}`,
building: `/api/buildings/${entry.buildingId}`
})
}
}
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 gap-x-40 gap-y-8 mb-16">
<UiNumberInput
label="Pesée à vide"
v-model="form.weights[0].weight"
:disabled="!auth.isAdmin"
:min="0"
/>
<UiNumberInput
label="Pesée à plein"
v-model="form.weights[1].weight"
:disabled="!auth.isAdmin"
:min="0"
/>
</div>
<div class="flex justify-center">
<button
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="!auth.isAdmin"
>
Valider
</button>
</div>
</form>
</template>
<script setup lang="ts">
import type {ReceptionFormWeight} from '~/services/dto/reception-data'
import { getReception } from '~/services/reception'
import {updateWeight} from "~/services/weight";
import {useAuthStore} from "~/stores/auth";
const props = defineProps<{
idReception: number
}>()
const idReception = props.idReception
const auth = useAuthStore()
const form = reactive({
weights: [
{ id: 0, type: 'tare' as const, weight: 0 },
{ id: 0, type: 'gross' as const, weight: 0 }
]
})
const hydrateFromReception = (reception: ReceptionFormWeight) => {
const tare = reception.weights.find(weight => weight.type === 'tare')
const gross = reception.weights.find(weight => weight.type === 'gross')
if (tare) form.weights[0] = { ...tare }
if (gross) form.weights[1] = { ...gross }
}
onMounted(async () => {
const reception = await getReception(idReception)
hydrateFromReception(reception)
})
async function validate() {
for (const weight of form.weights) {
if (weight.id) {
await updateWeight(weight.id, {weight: weight.weight})
}
}
}
</script>

View File

@@ -1,7 +1,8 @@
<template>
<form ref="formRef" :class="{ submitted }" @submit.prevent="validate">
<div class="grid grid-cols-2 h-[461px] items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1 text-primary-500">Expédition</h1>
<form @submit.prevent="validate">
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<h1 class="font-bold text-5xl uppercase col-start-1 row-start-1">Expédition</h1>
<!-- Nom de l'utilisateur -->
<UiSelect
id="shipment-user"
v-model="form.userId"
@@ -12,62 +13,57 @@
}))"
:loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2"
required
/>
<!-- Date de l'éxpedition -->
<UiDateInput
id="shipment-date"
v-model="form.shipmentDate"
label="Date du jour"
wrapper-class="col-start-1 row-start-3"
required
/>
<div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]">
<UiRadioGroup
id="shipment-type"
name="shipment-type"
label="Type d'expédition bovine"
input-class="accent-primary-700 focus:ring-primary-700"
wrapper-class=""
group-class="flex flex-row gap-[104px] w-[160px_160px] h-[32px]"
v-model="selectedShipmentTypeId"
:options="bovineShipment.map((type) => ({
value: String(type.id),
label: type.label
}))"
required
/>
<UiNumberInput
id="shipment-type-quantity"
v-model="shipmentQuantity"
:placeholder="0"
:min="0"
:max="1200"
:disabled="!selectedShipmentTypeId"
/>
<!-- Type d'expédition -->
<div class="col-start-1 row-start-4">
<label class="font-bold uppercase text-xl mb-2 block">
Type d'expédition
</label>
<div class="grid grid-cols-2 gap-x-8">
<div
v-for="type in bovineShipment"
:key="type.id"
class="mt-8 flex flex-row gap-6"
>
<UiNumberInput
:label="type.label"
v-model="bovineQuantities[String(type.id)]"
:placeholder="0"
:min="0"
:max="10"
/>
</div>
</div>
</div>
<!-- Client -->
<UiSelect
id="shipment-customer"
v-model="form.customerId"
label="Client"
:options="customers.map((customer) => ({
value: String(customer.id),
label: customer.name || `Client #${customer.id}`
label: customer.label
}))"
:loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5"
required
/>
<!-- Adresse du client -->
<UiSelect
id="shipment-address"
v-model="form.addressId"
:options="addressOptions"
:disabled="isLoadingCustomers || ownerAddresses.length === 0"
:options="customerAddressOptions"
:disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse"
wrapper-class="col-start-2 row-start-1"
required
/>
<!-- Camion -->
<UiSelect
id="shipment-truck"
v-model="form.truckId"
@@ -78,8 +74,8 @@
}))"
:loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2"
required
/>
<!-- Transporteur -->
<UiSelect
id="shipment-carrier"
v-model="form.carrierId"
@@ -89,15 +85,27 @@
label: carrier.name
}))"
wrapper-class="col-start-2 row-start-3"
required
/>
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<!-- Chauffeur (LIOT) -->
<UiSelect
id="shipment-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="col-start-2 row-start-4"
/>
<!-- Plaque d'immatriculation (hors LIOT) -->
<div v-if="!isLiotCarrier" class="col-start-2 row-start-5">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model="form.licencePlate"
v-model:allowAny="allowAnyLicensePlate"
required
/>
</div>
<!-- Immatriculation (LIOT) -->
<UiSelect
v-if="isLiotCarrier"
id="shipment-vehicle"
@@ -109,50 +117,73 @@
}))"
:loading="isLoadingVehicles"
:disabled="isLoadingVehicles || filteredVehicles.length === 0"
wrapper-class="col-start-2 row-start-4"
required
/>
<UiSelect
id="shipment-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="col-start-2 row-start-5"
v-if="isLiotCarrier"
required
/>
</div>
<div class="flex justify-center">
<UiButton
<button
type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Valider
</UiButton>
</button>
</div>
</form>
</template>
<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'
const router = useRouter()
const shipmentStore = useShipmentStore()
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";
import {
createShipmentBovine,
deleteShipmentBovine,
getBovinShipmentList,
updateShipmentBovine
} from "~/services/bovin-shipment";
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 submitted = ref(false)
const formRef = ref<HTMLFormElement | null>(null)
const isLoadingVehicles = ref(false)
const allowAnyLicensePlate = ref(false)
const isLoadingDrivers = ref(false)
const authStore = useAuthStore()
const shipmentStore = useShipmentStore()
const router = useRouter()
const bovineQuantities = ref<Record<string, number | null>>({})
const bovineShipment = ref<ShipmentTypeData[]>([])
// 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>({
userId: '',
@@ -163,26 +194,60 @@ const form = reactive<ShipmentFormData>({
carrierId: '',
driverId: '',
vehicleId: '',
licensePlate: '',
licencePlate: '',
})
// 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 customers = ref<CustomerData[]>([])
const isLoadingCustomers = ref(false)
const bovineShipment = ref<ShipmentTypeData[]>([])
const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0)
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 loadShipmentType = async () => {
isLoadingShipmentTypes.value = true
try {
bovineShipment.value = await getShipmentTypeList()
} finally {
isLoadingShipmentTypes.value = false
}
}
const loadCustomers = async () => {
isLoadingCustomers.value = true
@@ -191,53 +256,267 @@ const loadCustomers = async () => {
} finally {
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(
() => shipmentStore.current,
(shipment) => {
isHydrating.value = true
form.licensePlate = shipment?.licensePlate ?? ''
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.customerId = shipment?.customer?.id ? String(shipment.customer.id) : ''
form.licencePlate = shipment?.licencePlate ?? ''
form.shipmentDate = shipment?.shipmentDate ?? new Date().toISOString().slice(0, 10)
form.userId = shipment?.user?.id ? String(shipment.user.id) :
form.userId
form.customerId = shipment?.customer?.id ?
String(shipment.customer.id) : ''
form.addressId = shipment?.address?.id ? String(shipment.address.id) : ''
form.truckId = shipment?.truck?.id ? String(shipment.truck.id) : ''
form.carrierId = shipment?.carrier?.id ? String(shipment.carrier.id) : ''
form.driverId = shipment?.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment?.vehicle?.id ? String(shipment.vehicle.id) : ''
selectedShipmentTypeId.value = shipment?.shipmentType?.id ? String(shipment.shipmentType.id) : ''
shipmentQuantity.value = shipment?.nbBovinSend ?? 0
if (!shipment || !shipment.bovinShipments) {
bovineQuantities.value = {}
} else {
const next: Record<string, number | null> = {}
for (const entry of shipment.bovinShipments) {
const typeId = entry.shipmentType?.id
if (!typeId) continue
next[String(typeId)] = entry.nbBovinSend ?? null
}
bovineQuantities.value = next
}
isHydrating.value = false
},
{ immediate: true }
{immediate: true}
)
// Ajuste driver/vehicle quand le transporteur change (logique LIOT)
watch(
() => [form.customerId, 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) {
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(
() => isHydrating.value,
(value) => {
if (!value && isLiotCarrier.value) {
if (filteredDrivers.value.length === 1 && !form.driverId) {
form.driverId = String(filteredDrivers.value[0].id)
}
if (filteredVehicles.value.length === 1 && !form.vehicleId) {
form.vehicleId = String(filteredVehicles.value[0].id)
}
if (!value) {
applyLiotDefaults()
}
}
)
// 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.licencePlate = selected.plate
allowAnyLicensePlate.value = false
}
}
)
watch(
() => [form.licencePlate, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || form.vehicleId) {
return
}
const match = filteredVehicles.value.find(
(vehicle) => vehicle.plate === form.licencePlate
)
if (match) {
form.vehicleId = String(match.id)
}
}
)
const buildDesiredBovinShipments = () => {
return bovineShipment.value
.map((type) => {
const raw = bovineQuantities.value[String(type.id)]
const quantity = raw === null || raw === undefined ? 0 : Number(raw)
return {
type,
quantity: Number.isFinite(quantity) ? Math.max(0, Math.trunc(quantity)) : 0
}
})
.filter((entry) => entry.quantity > 0)
}
const syncBovinShipments = async (
shipmentId: number,
existing: Array<{ id?: number; nbBovinSend: number | null; shipmentType?: unknown }> = []
) => {
const shipmentIri = `/api/shipments/${shipmentId}`
const desired = buildDesiredBovinShipments()
const desiredByTypeId = new Map<number, number>()
for (const entry of desired) {
desiredByTypeId.set(entry.type.id, entry.quantity)
}
for (const entry of existing) {
if (!entry.id) {
continue
}
const rawType = entry.shipmentType
let typeId: number | null = null
if (rawType && typeof rawType === 'object' && 'id' in rawType) {
typeId = Number((rawType as { id: number }).id)
} else if (typeof rawType === 'string') {
const match = rawType.match(/\/shipment_types\/(\\d+)$/)
typeId = match ? Number(match[1]) : null
}
if (!typeId) {
continue
}
const desiredQuantity = desiredByTypeId.get(typeId)
if (!desiredQuantity) {
await deleteShipmentBovine(entry.id)
continue
}
if (entry.nbBovinSend !== desiredQuantity) {
await updateShipmentBovine(entry.id, {nbBovinSend: desiredQuantity})
}
desiredByTypeId.delete(typeId)
}
onMounted(async () => {
bovineShipment.value = await getShipmentTypeList()
await loadCustomers()
await loadCommonData()
await loadVehicles()
await loadDrivers()
})
for (const [typeId, quantity] of desiredByTypeId.entries()) {
await createShipmentBovine({
shipment: shipmentIri,
shipmentType: `/api/shipment_types/${typeId}`,
nbBovinSend: quantity
})
}
}
const buildPayload = () => {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedLicensePlate = form.licencePlate.trim()
const normalizedShipmentDate = form.shipmentDate.trim()
const normalizedCustomerId = form.customerId.trim()
const normalizedTruckId = form.truckId.trim()
@@ -245,55 +524,62 @@ const buildPayload = () => {
const normalizedDriverId = form.driverId.trim()
const normalizedUserId = form.userId.trim()
const normalizedAddressId = form.addressId.trim()
const customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const carrierIri = normalizedCarrierId ? `/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 shipmentTypeIri = normalizedShipmentTypeId ? `/api/shipment_types/${normalizedShipmentTypeId}` : null
const rawQuantity = Number(shipmentQuantity.value ?? 0)
const normalizedQuantity = Number.isFinite(rawQuantity) ? Math.max(0, Math.trunc(rawQuantity)) : 0
const customerIri = normalizedCustomerId
? `/api/customers/${normalizedCustomerId}`
: null
const truckIri = normalizedTruckId
? `/api/trucks/${normalizedTruckId}`
: null
const carrierIri = normalizedCarrierId
? `/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
return {
licensePlate: normalizedLicensePlate,
licencePlate: normalizedLicensePlate,
shipmentDate: normalizedShipmentDate,
customer: customerIri,
truck: truckIri,
carrier: carrierIri,
driver: driverIri,
user: userIri,
address: addressIri,
shipmentType: shipmentTypeIri,
nbBovinSend: normalizedQuantity,
address: addressIri
}
}
const saveDraft = async () => {
const payload = buildPayload()
if (!shipmentStore.current) {
await shipmentStore.createShipment({
const created = await shipmentStore.createShipment({
currentStep: 0,
...payload
})
if (created) {
await syncBovinShipments(created.id, [])
}
return
}
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: shipmentStore.current.currentStep,
...payload
})
await syncBovinShipments(
shipmentStore.current.id,
shipmentStore.current?.bovinShipments ?? []
)
}
const validateFields = () => {
submitted.value = true
return formRef.value?.reportValidity() ?? false
}
defineExpose({ saveDraft, validateFields })
defineExpose({saveDraft})
// Valide le formulaire et crée/met à jour l'expédition
const validate = async () => {
const payload = buildPayload()
if (!shipmentStore.current) {
@@ -303,6 +589,7 @@ const validate = async () => {
})
if (created) {
await shipmentStore.loadShipment(created.id)
await syncBovinShipments(created.id, shipmentStore.current?.bovinShipments ?? [])
await router.push(`/shipment/${created.id}`)
}
return
@@ -313,5 +600,6 @@ const validate = async () => {
...payload
})
await shipmentStore.loadShipment(shipmentStore.current.id)
await syncBovinShipments(shipmentStore.current.id, shipmentStore.current?.bovinShipments ?? [])
}
</script>

View File

@@ -1,26 +0,0 @@
<template>
<div class="flex flex-col items-center gap-[150px]">
<h1 class="font-bold text-5xl uppercase text-primary-500">Chargement des bovins</h1>
<div
class="w-full flex flex-col items-center justify-center">
<UiLoadingDots />
</div>
<UiButton
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] ml-4"
@click="goNext"
>Peser</UiButton>
</div>
</template>
<script setup lang="ts">
import {useShipmentStore} from "~/stores/shipment";
const shipmentStore = useShipmentStore()
const goNext = async () => {
const nextStep = shipmentStore.current.currentStep + 1
await shipmentStore.updateShipment(shipmentStore.current.id, {
currentStep: nextStep
})
}
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="flex justify-center">
<div class="flex flex-col items-center w-[660px]">
<h1 class="font-bold text-5xl uppercase">{{ 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-black h-[90px] mt-12 mb-[25px] text-4xl">
{{ displayWeight }} kg
</div>
</div>
</div>
</div>
<div class="flex justify-center mt-[54px]">
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
@click="fetchWeight"
>{{ displayWeight !== null ? 'refaire une pesee' : 'peser' }}</button>
<button
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</button>
<button
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</button>
</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.licencePlate ?? '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

@@ -1,39 +0,0 @@
<template>
<component
:is="'button'"
:type="type"
:disabled="isDisabled"
class="inline-flex min-w-[194px] items-center justify-center rounded-md"
:class="[
isDisabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
buttonClass
]"
v-bind="attrs"
>
<slot v-if="!loading" />
<UiLoadingDots v-else />
</component>
</template>
<script setup lang="ts">
import {computed, useAttrs} from 'vue'
defineOptions({inheritAttrs: false})
const props = withDefaults(
defineProps<{
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
loading?: boolean
buttonClass?: string
}>(),
{
disabled: false,
loading: false,
buttonClass: ''
}
)
const attrs = useAttrs()
const isDisabled = computed(() => props.disabled || props.loading)
</script>

View File

@@ -1,14 +1,14 @@
<template>
<div :class="wrapperClass">
<label
class="flex items-center gap-2 cursor-pointer text-primary-700"
class="flex items-center gap-2"
:class="labelClass"
>
<input
type="checkbox"
:checked="checked"
:disabled="disabled"
:class="['h-4 w-4 cursor-pointer text-primary-500', inputClass]"
:class="inputClass"
@change="onChange"
>
<span v-if="label">{{ label }}</span>

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
class="font-bold uppercase text-xl mb-2"
:class="labelClass"
>
{{ label }}
@@ -14,9 +14,9 @@
:value="modelValue ?? ''"
:disabled="disabled"
v-bind="attrs"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
class="border-b border-black justify-self-start text-xl pb-[6px] uppercase bg-transparent appearance-none h-[34px]"
:class="[
isEmpty ? 'text-neutral-400' : 'text-primary-700',
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass
]"

View File

@@ -1,10 +1,9 @@
// flex row passer en class wraper class flex col ainsi que le wfull 34
<template>
<div :class="['flex', wrapperClass]">
<div :class="['flex flex-row items-center gap-2', wrapperClass]">
<label
v-if="label"
:for="id"
class="text-xl flex items-center gap-2 text-primary-700"
class="text-xl text-bold flex items-center"
:class="labelClass"
>
<span
@@ -26,7 +25,7 @@
:step="step"
:disabled="disabled"
v-bind="attrs"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] uppercase bg-transparent appearance-none h-[34px]"
class="border-b border-black text-xl bg-transparent w-12"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-text',
@@ -75,41 +74,14 @@ const emit = defineEmits<{
const attrs = useAttrs()
const isEmpty = computed(() => props.modelValue === null || props.modelValue === undefined || props.modelValue === '')
const toNumberOrNull = (value: number | string | undefined) => {
if (value === undefined || value === '') {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const onInput = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.value === '') {
emit('update:modelValue', null)
return
}
const parsed = Number(target.value)
if (!Number.isFinite(parsed)) {
emit('update:modelValue', null)
return
}
const min = toNumberOrNull(props.min)
const max = toNumberOrNull(props.max)
let numeric = parsed
if (min !== null) {
numeric = Math.max(min, numeric)
} else {
numeric = Math.max(0, numeric)
}
if (max !== null) {
numeric = Math.min(max, numeric)
}
target.value = String(numeric)
emit('update:modelValue', numeric)
const numeric = Math.max(0, Number(target.value))
emit('update:modelValue', Number.isNaN(numeric) ? null : numeric)
}
const onKeydown = (event: KeyboardEvent) => {

View File

@@ -1,93 +0,0 @@
<template>
<div :class="['flex flex-col', wrapperClass]">
<label
v-if="label"
class="font-bold uppercase text-xl text-primary-700"
:class="labelClass"
>
{{ label }}
</label>
<div
role="radiogroup"
:aria-label="label || id || 'radio-group'"
:class="['flex items-center gap-6 mt-1', groupClass]"
>
<label
v-for="option in options"
:key="String(option.value)"
:for="`${id || 'radio'}-${option.value}`"
class="flex items-center gap-2 text-primary-700"
:class="itemClass"
>
<input
:id="`${id || 'radio'}-${option.value}`"
type="radio"
:name="name || id || 'radio-group'"
:value="String(option.value)"
:checked="String(modelValue ?? '') === String(option.value)"
:disabled="disabled"
v-bind="attrs"
class="h-4 w-4 border-primary-700/50 text-primary-700 focus:ring-primary-700"
:class="[
disabled ? 'cursor-not-allowed' : 'cursor-pointer',
inputClass
]"
@change="onChange"
>
<span class="text-xl" :class="optionLabelClass">
{{ option.label }}
</span>
</label>
</div>
</div>
</template>
<script setup lang="ts">
import { useAttrs } from 'vue'
type RadioOption = {
value: string | number
label: string
}
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
id?: string
name?: string
label?: string
modelValue: string | number | null | undefined
options: RadioOption[]
disabled?: boolean
wrapperClass?: string
labelClass?: string
groupClass?: string
itemClass?: string
inputClass?: string
optionLabelClass?: string
}>(),
{
name: '',
label: '',
disabled: false,
wrapperClass: '',
labelClass: '',
groupClass: '',
itemClass: '',
inputClass: '',
optionLabelClass: ''
}
)
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
}>()
const attrs = useAttrs()
const onChange = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
}
</script>

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
class="font-bold uppercase text-xl mb-2"
:class="labelClass"
>
{{ label }}
@@ -13,9 +13,9 @@
:value="modelValue ?? ''"
:disabled="disabled || loading"
v-bind="attrs"
class="border-b border-primary-700 justify-self-start text-xl text-primary-700 py-[6px] bg-transparent"
class="border-b border-black justify-self-start text-xl pb-[6px] bg-transparent"
:class="[
isEmpty ? 'text-neutral-400' : 'text-primary-700',
isEmpty ? 'text-neutral-400' : 'text-black',
disabled || loading ? 'cursor-not-allowed' : 'cursor-pointer',
selectClass
]"
@@ -28,7 +28,7 @@
v-for="option in options"
:key="option.value"
:value="option.value"
class="text-primary-700"
class="text-black"
>
{{ option.label }}
</option>

View File

@@ -3,7 +3,7 @@
<label
v-if="label"
:for="id"
class="font-bold uppercase text-xl text-primary-700"
class="font-bold uppercase text-xl mb-2"
:class="labelClass"
>
{{ label }}
@@ -16,7 +16,7 @@
:maxlength="maxlength"
:disabled="disabled"
v-bind="attrs"
class="border-b border-black text-xl py-[6px] bg-transparent text-primary-700"
class="border-b border-black text-xl pb-[6px] bg-transparent"
:class="[
isEmpty ? 'text-neutral-400' : 'text-black',
disabled ? 'cursor-not-allowed' : 'cursor-text',

View File

@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col">
<label :for="inputId" class="font-bold uppercase text-xl text-primary-500">{{ label }}</label>
<label :for="inputId" class="font-bold uppercase text-xl mb-2">{{ label }}</label>
<div class="flex items-end gap-8">
<input
:id="inputId"
@@ -9,8 +9,7 @@
type="text"
:maxlength="maxLength"
:placeholder="placeholderText"
:required="required"
class="border-b border-black flex-1 min-w-0 text-xl text-primary-500 uppercase h-[36px] py-[6px]"
class="border-b border-black flex-1 min-w-0 text-xl uppercase h-[30px]"
@input="handleInput"
/>
<UiCheckbox
@@ -33,14 +32,12 @@ type Props = {
allowAny?: boolean
label?: string
id?: string
required?: boolean
}
const props = withDefaults(defineProps<Props>(), {
allowAny: false,
label: 'Immatriculation',
id: 'license-plate',
required: false
id: 'license-plate'
})
const emit = defineEmits<{

View File

@@ -4,7 +4,7 @@
<div
v-for="(label, index) in labels"
:key="label"
class="absolute top-0 whitespace-nowrap text-primary-500"
class="absolute top-0 whitespace-nowrap"
:class="labelClass(index)"
:style="positionStyle(index)"
>

View File

@@ -1,57 +0,0 @@
<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

@@ -1,72 +0,0 @@
<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

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

View File

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

View File

@@ -1,54 +0,0 @@
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

@@ -1,113 +0,0 @@
import { ref, onUnmounted } from 'vue'
declare global {
interface Window {
BarcodeDetector: new (options?: { formats: string[] }) => {
detect(source: HTMLVideoElement | ImageBitmapSource): Promise<{ rawValue: string }[]>
}
}
const BarcodeDetector: Window['BarcodeDetector'] | undefined
}
export function useBarcodeScanner(onDetected: (code: string) => void) {
const isSupported = ref('BarcodeDetector' in globalThis)
const isScanning = ref(false)
const error = ref<string | null>(null)
let detector: InstanceType<Window['BarcodeDetector']> | null = null
let stream: MediaStream | null = null
let animationFrameId: number | null = null
let lastDetectedCode = ''
let lastDetectedTime = 0
const COOLDOWN_MS = 2000
async function start(videoElement: HTMLVideoElement) {
if (!isSupported.value) {
error.value = 'BarcodeDetector non supporté. Utilisez Chrome sur Android.'
return
}
try {
detector = new BarcodeDetector({ formats: ['code_39', 'code_128'] })
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1280 },
height: { ideal: 720 }
}
})
videoElement.srcObject = stream
await videoElement.play()
isScanning.value = true
error.value = null
scanLoop(videoElement)
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur lors du démarrage de la caméra'
isScanning.value = false
}
}
function scanLoop(videoElement: HTMLVideoElement) {
if (!isScanning.value || !detector) return
animationFrameId = requestAnimationFrame(async () => {
try {
if (videoElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA) {
const barcodes = await detector!.detect(videoElement)
if (barcodes.length > 0) {
const code = barcodes[0].rawValue.slice(4)
const now = Date.now()
if (code !== lastDetectedCode || now - lastDetectedTime > COOLDOWN_MS) {
lastDetectedCode = code
lastDetectedTime = now
if (navigator.vibrate) {
navigator.vibrate(100)
}
onDetected(code)
}
}
}
} catch {
// Detection error on single frame, continue
}
scanLoop(videoElement)
})
}
function stop() {
isScanning.value = false
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
if (stream) {
stream.getTracks().forEach(track => track.stop())
stream = null
}
detector = null
}
onUnmounted(() => {
stop()
})
return {
isSupported,
isScanning,
error,
start,
stop
}
}

View File

@@ -1,73 +0,0 @@
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

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

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

View File

@@ -1,25 +0,0 @@
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

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

View File

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

View File

@@ -12,7 +12,6 @@
"fetch": "Impossible de récupérer la réception.",
"create": "Impossible de créer 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": {
@@ -23,7 +22,6 @@
"fetch": "Impossible de récupérer l'éxpeditions.",
"create": "Impossible de créer 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."
},
"shipmentBovine": {
@@ -84,13 +82,7 @@
"list": "Impossible de récupérer la liste des camions."
},
"bovin": {
"list": "Impossible de récupérer la liste des races de bovins.",
"fetch": "Impossible de récupérer le type bovin.",
"create": "Impossible de créer le type bovin.",
"update": "Impossible de mettre à jour le type bovin."
},
"bovine": {
"create": "Impossible d'enregistrer le bovin."
"list": "Impossible de récupérer la liste des races de bovins."
},
"carrier": {
"list": "Impossible de récupérer la liste des transporteurs.",
@@ -114,23 +106,15 @@
},
"success": {
"reception": {
"create": "Réception créée avec succès",
"update": "Réception mise à jour avec succès.",
"delete": "Réception supprimée avec succès."
"update": "Réception mise à jour avec succès."
},
"shipment": {
"create": "Éxpedition créée avec succès",
"update": "Éxpedition mise à jour avec succès.",
"delete": "Expédition supprimée avec succès."
"update": "Éxpedition mise à jour avec succès."
},
"supplier": {
"create": "Fournisseur créé avec succès.",
"update": "Fournisseur mis à jour avec succès."
},
"customer": {
"create": "Client créé avec succès.",
"update": "Client mis à jour avec succès."
},
"address": {
"create": "Adresse créée avec succès.",
"update": "Adresse mise à jour avec succès."
@@ -145,13 +129,6 @@
"update": "Transporteur mis à jour",
"create": "Transporteur créé"
},
"bovin": {
"update": "Type bovin mis à jour avec succès.",
"create": "Type bovin créé avec succès."
},
"bovine": {
"create": "Bovin enregistré avec succès."
},
"weight": {
"update": "Pesée mis à jour"
}

View File

@@ -0,0 +1,74 @@
<template>
<div class="min-h-screen text-neutral-900 grid grid-rows-[85px,1fr]">
<!-- HEADER -->
<header class="bg-primary-500 z-50 h-[85px]">
<div class="h-full w-full px-6 grid grid-cols-[auto,1fr,auto] items-center gap-8">
<NuxtLink to="/" class="grid place-items-center">
<span class="grid place-items-center bg-white text-xl font-bold uppercase text-primary-500 p-4">
LOGO
</span>
</NuxtLink>
<nav class="text-2xl font-bold uppercase text-white"></nav>
<NuxtLink
to="/"
class="text-xl font-bold uppercase text-white transition hover:opacity-80 justify-self-end"
>
Quitter le panel admin
</NuxtLink>
</div>
</header>
<div class="grid grid-cols-[16rem,1fr] h-[calc(100vh-85px)] min-h-0">
<aside class="bg-primary-500 text-white min-h-0 flex flex-col justify-between">
<div class="flex flex-col gap-4 p-4 font-bold text-xl">
<!-- Liste des liens à ajouter ci-dessous -->
<NuxtLink to="/admin/dashboard">
Tableau de bord
</NuxtLink>
<NuxtLink to="/admin/supplier/supplier-list">
Fournisseur
</NuxtLink>
<NuxtLink to="/admin/carrier/carrier-list">
Transporteur
</NuxtLink>
<NuxtLink to="/admin/user/list">
Utilisateurs
</NuxtLink>
</div>
<div class="p-4">
<p class="font-bold text-white text-left">v{{ version }}</p>
<button
@click="handleLogout"
class="w-full bg-red-600 hover:bg-red-700 py-2 rounded font-bold"
>
Déconnexion
</button>
</div>
</aside>
<main class="min-h-0 overflow-auto px-12 py-12 ">
<div class="w-full ">
<slot />
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import {useAuthStore} from '~/stores/auth'
const auth = useAuthStore()
const { version } = useAppVersion()
const handleLogout = async () => {
try {
await auth.logout()
} finally {
await navigateTo('/login')
}
}
</script>

View File

@@ -1,183 +1,53 @@
<template>
<div class="min-h-screen text-neutral-900 flex flex-col">
<!-- HEADER -->
<header class="w-full bg-primary-500 py-5 px-6">
<div class="flex w-full items-center justify-between">
<!-- Burger (mobile) -->
<div class="min-h-screen bg-white text-neutral-900">
<header class="w-full border-b border-neutral-200 bg-primary-500">
<div class="flex w-full items-center justify-center px-6 py-4">
<button
type="button"
class="inline-flex items-center justify-center text-3xl text-white md:hidden"
aria-label="Ouvrir le menu"
@click="toggleMenu"
>
<span aria-hidden="true" class="flex items-center">
<Icon name="mdi:menu" size="44"/>
</span>
<span aria-hidden="true" class="flex items-center"><Icon name="mdi:menu" size="44"/></span>
</button>
<!-- Logo -->
<NuxtLink to="/" class="shrink-0">
<span class="flex items-center justify-center bg-white text-xl font-bold uppercase px-6 py-4">
LOGO
</span>
</NuxtLink>
<!-- NAV centré (desktop) -->
<nav
class="hidden md:flex flex-1 items-center justify-center gap-8 text-xl font-bold uppercase text-white"
>
<NuxtLink to="/" custom v-slot="{ href, navigate }">
<nav class="ml-4 hidden items-center gap-8 text-2xl font-bold uppercase text-white md:flex">
<NuxtLink to="/" custom v-slot="{ href, navigate, isExactActive }">
<a
:href="href"
@click="navigate"
:class="route.path === '/'
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
:class="isExactActive ? 'opacity-100' : 'opacity-50'"
>
Accueil
</a>
</NuxtLink>
<NuxtLink
to="/admin/dashboard" custom v-slot="{ href, navigate, isActive }"
v-if="auth.isAdmin"
to="/admin/user/list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/user')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Utilisateurs
</a>
</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/customer/customer-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/customer')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Clients
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/carrier/carrier-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/carrier')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Transporteurs
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/admin/bovin/bovin-list"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/admin/bovin')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Bovins
</a>
</NuxtLink>
<NuxtLink
v-if="auth.isAdmin"
to="/scan"
custom
v-slot="{ href, navigate }"
>
<a
:href="href"
@click="navigate"
:class="route.path.startsWith('/scan')
? 'opacity-100'
: 'opacity-65 hover:opacity-100 transition'"
>
Scanner
Admin
</a>
</NuxtLink>
</nav>
<!-- Spacer mobile (pour centrer visuellement le header si besoin) -->
<NuxtLink to="/" class="flex flex-1 items-center justify-center gap-3">
<span
class="flex items-center justify-center bg-white text-xl font-bold uppercase text-primary-500 p-4"
>
LOGO
</span>
</NuxtLink>
<div class="w-[44px] md:hidden"></div>
<!-- User dropdown à droite (desktop) -->
<div v-if="auth.isAuthenticated" class="ml-auto relative hidden md:flex items-center text-white group">
<button
type="button"
class="inline-flex items-center py-2 -my-2 text-xl leading-none transition hover:opacity-80"
aria-haspopup="true"
>
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
<span class="capitalize font-bold ml-4">{{ userDisplayName }}</span>
<span
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"/>
</span>
</button>
<div
class="absolute right-0 top-full z-10 w-56 rounded-md bg-primary-500 py-2 border-neutral-300 border shadow-lg
opacity-0 invisible pointer-events-none transition
group-hover:opacity-100 group-hover:visible group-hover:pointer-events-auto
group-focus-within:opacity-100 group-focus-within:visible group-focus-within:pointer-events-auto"
role="menu"
>
<button
type="button"
class="w-full px-4 py-2 text-left text-sm font-semibold text-white opacity-85 hover:opacity-100 transition"
@click="handleLogout"
>
Déconnexion
</button>
</div>
</div>
<button
type="button"
class="ml-auto hidden text-xl font-bold uppercase text-white transition hover:opacity-80 md:inline-flex"
@click="handleLogout"
>
Déconnexion
</button>
</div>
<!-- Overlay (mobile) -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
@@ -192,8 +62,6 @@
@click="closeMenu"
/>
</transition>
<!-- Drawer (mobile) -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="-translate-x-full"
@@ -204,7 +72,9 @@
>
<aside
v-if="isMenuOpen"
class="fixed left-0 top-0 z-50 h-full w-full bg-primary-500 px-6 pb-8 pt-6 text-white shadow-xl md:hidden"
class="fixed left-0 top-0 z-50 h-full w-full bg-primary-600 px-6 pb-8 pt-6 text-white shadow-xl md:hidden"
role="dialog"
aria-modal="true"
>
<div class="flex items-center justify-between">
<span class="text-2xl font-bold uppercase">Menu</span>
@@ -217,33 +87,12 @@
<Icon name="mdi:close" size="44"/>
</button>
</div>
<nav class="mt-8 flex flex-col gap-6 text-xl font-bold uppercase">
<NuxtLink to="/admin/dashboard" @click="closeMenu">Accueil</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/supplier/supplier-list" @click="closeMenu">
Fournisseurs
</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/carrier/carrier-list" @click="closeMenu">
Transporteurs
</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/user/list" @click="closeMenu">
Utilisateurs
</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/customer/customer-list" @click="closeMenu">
Clients
</NuxtLink>
<NuxtLink v-if="auth.isAdmin" to="/admin/bovin/bovin-list" @click="closeMenu">
Bovins
</NuxtLink>
<NuxtLink to="/scan" @click="closeMenu">
Scanner
</NuxtLink>
<NuxtLink to="/" class="opacity-100" @click="closeMenu">Accueil</NuxtLink>
</nav>
<button
v-if="auth.isAuthenticated"
type="button"
class="mt-6 text-xl font-bold uppercase"
class="mt-5 text-xl font-bold uppercase"
@click="handleLogout"
>
Déconnexion
@@ -251,10 +100,10 @@
</aside>
</transition>
</header>
<main class="md:mx-auto w-full md:max-w-[1280px] mt-4 md:mt-16">
<main class="mx-auto w-full max-w-[1280px] pb-0">
<slot/>
</main>
<footer class="w-full mt-auto bg-primary-500 px-6 py-3">
<footer class="w-full mt-8 bg-primary-500 p-6">
<p class="font-bold text-white text-right">v{{ version }}</p>
</footer>
</div>
@@ -265,11 +114,8 @@ import {useAuthStore} from '~/stores/auth'
const route = useRoute()
const auth = useAuthStore()
const {version} = useAppVersion()
const isMenuOpen = ref(false)
const userDisplayName = computed(() => auth.user?.username ?? 'Utilisateur')
const {version} = useAppVersion()
const closeMenu = () => {
isMenuOpen.value = false

View File

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

View File

@@ -1,112 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<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>
</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
type="submit"
:disabled="isLoading || isHydrating"
class="inline-flex items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
@click="submitted = true"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import {createBovin, getBovin, updateBovin} from "~/services/bovine-type";
import type {BovineTypeData, BovinFormData} from "~/services/dto/bovine-type-data";
const router = useRouter()
const route = useRoute()
const isLoading = 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) {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) return null
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
const form = reactive<BovinFormData>({
label: '',
code: ''
})
const hydrateFromBovin = (bovin: BovineTypeData | null) => {
if (!bovin) {
return
}
isHydrating.value = true
form.label = bovin.label ?? ''
form.code = bovin.code ?? ''
isHydrating.value = false
}
watch(
() => idBovin.value,
async (id) => {
if (id === null) {
return
}
isLoading.value = true
try {
const bovin = await getBovin(id)
hydrateFromBovin(bovin)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value || isHydrating.value) return
const normalizedBovinCode = form.code.trim()
const normalizedBovinLabel = form.label.trim()
const basePayload = {
label: normalizedBovinLabel,
code: normalizedBovinCode
}
isLoading.value = true
try {
if (isEdit.value && idBovin.value !== null) {
await updateBovin(idBovin.value, basePayload)
} else {
await createBovin(basePayload)
}
} finally {
isLoading.value = false
}
}
async function navigate(){
return router.push("/admin/bovin/list")
}
</script>

View File

@@ -1,67 +0,0 @@
<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,44 +1,31 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@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>
<form @submit.prevent="validate">
<div class="flex items-center justify-between ">
<h1 class="text-3xl font-bold uppercase">
{{ route.params.id ? 'Modifier transporteur' : 'Ajout transporteur' }}
</h1>
<div class="grid grid-cols-2 items-start pt-7 mb-11 gap-x-[200px]">
<UiTextInput
label="Nom du transporteur"
id="carrier-name"
v-model="form.name"
required
/>
<button
type="submit"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
>Enregistrer
</button>
</div>
<UiTextInput
label="Code transporteur"
<div class="grid grid-cols-2 items-start gap-y-8 gap-x-40 mb-16">
<UiTextInput
label = "nom du fournisseur"
id="carrier-name"
v-model="form.name"
/>
<UiTextInput
label = "code fournisseur"
id="code-id"
v-model="form.code"
required
/>
</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>
/>
</div>
</form>
</template>
@@ -46,26 +33,21 @@
<script setup lang="ts">
import {createCarrier, getCarrier, updateCarrier} from "~/services/carrier";
import type {CarrierData, CarrierFormData} from "~/services/dto/carrier-data";
import {computed} from "vue";
const router = useRouter()
const route = useRoute()
const idCarrier = computed(() => resolveId(route.params.id))
const idCarrier = Number(route.params.id)
const isLoading = ref(false)
const isHydrating = ref(false)
const submitted = ref(false)
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 form = reactive<CarrierFormData>({
code:'',
name:''
})
definePageMeta({
layout: 'admin'
})
const hydrateFromUser = (carrier: CarrierData | null) => {
if (!carrier) {
return
@@ -77,7 +59,7 @@ const hydrateFromUser = (carrier: CarrierData | null) => {
}
watch(
() => idCarrier.value,
() => idCarrier,
async (id) => {
if (id === null) {
return
@@ -103,12 +85,13 @@ async function validate() {
}
if(idCarrier.value){
await updateCarrier(idCarrier.value, basePayload)
if(idCarrier){
await updateCarrier(idCarrier, basePayload)
navigate()
return
}else{
await createCarrier(basePayload)
}
await createCarrier(basePayload)
navigate()
}
function navigate(){

View File

@@ -1,18 +1,23 @@
<template>
<div class="flex items-center justify-between ">
<h1 class="text-4xl font-bold uppercase text-primary-500">listes des transporteurs</h1>
<h1 class="text-3xl font-bold uppercase">listes des transporteurs</h1>
<NuxtLink
to="/admin/carrier"
class="flex items-center justify-center text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
>Ajouter
</NuxtLink>
</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 class="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-2 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Label</div>
<div>Code</div>
</div>
<div
v-for="carrier in carrierList"
:key="carrier.id"
class="grid grid-cols-2 text-primary-700 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
class="grid grid-cols-2 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goToCarrier(carrier.id)"
@@ -22,15 +27,6 @@
<div>{{ carrier.code }}</div>
</div>
</div>
<div class="flex justify-center items-center">
<NuxtLink
to="/admin/carrier"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-8 rounded hover:opacity-80 gap-2"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
</template>
@@ -45,6 +41,10 @@ const goToCarrier = (id: number) => {
router.push(`/admin/carrier/${id}`)
}
definePageMeta({
layout: 'admin'
})
onMounted(async () => {
carrierList.value = await getCarrierList(false)
})

View File

@@ -1,249 +0,0 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<div class="flex flex-row absolute -left-[60px] ">
<Icon @click="router.push('/admin/customer/customer-list')" name="gg:arrow-left-o" size="40" class="cursor-pointer text-primary-500"/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ customerId ? "Modification du client" : "Ajout d'un client" }}
</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
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 || !auth.isAdmin"
@click="submitted = true"
>
<Icon :name="customerId ? '' : 'mdi:plus'" size="28" />
{{ customerId ? "Valider" : "Ajouter" }}
</UiButton>
</div>
<template v-if="customerId">
<div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du client</h2>
</div>
<div class="overflow-x-auto mb-11 text-primary-700">
<table class="w-full border-collapse text-primary-700">
<thead>
<tr class="text-left border bg-slate-100 border-gray-200">
<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">Code postal</th>
<th class="py-3 px-4 text-sm uppercase">Ville</th>
<th class="py-3 px-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="5" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
</template>
<template v-else>
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<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.postalCode || "—" }}</td>
<td class="py-3 px-4">{{ address.city || "—" }}</td>
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</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>
</template>
<script setup lang="ts">
import {computed, reactive, ref, watch} from "vue"
import {createCustomer, getCustomer, updateCustomer} from "~/services/customer"
import type {CustomerData, CustomerFormData, CustomerPayload} from "~/services/dto/customer-data"
import {createAddress, type AddressPayload} from "~/services/address"
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
import {useAuthStore} from "~/stores/auth"
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
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 customerId = computed(() => resolveId(route.params.id))
const isLoading = ref(false)
const submitted = ref(false)
const form = reactive<CustomerFormData>({
name: "",
phone: "",
email: "",
addresses: [],
})
// Address form (creation mode only)
const addressForm = reactive<AddressPayload>({
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
})
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => addressForm.postalCode, (cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
else addressForm.city = ''
} finally { isLoadingCities.value = false }
}, 300)
}
})
const goToAddAddress = () => {
if (customerId.value === null || !auth.isAdmin) return
router.push({
path: "/admin/customer/address",
query: {
customerId: String(customerId.value),
},
})
}
const goToEditAddress = (addressId: number | null) => {
if (customerId.value === null || addressId === null || !auth.isAdmin) return
router.push({
path: "/admin/customer/address",
query: {
customerId: String(customerId.value),
addressId: String(addressId),
},
})
}
const hydrateFromCustomer = (customer: CustomerData | null) => {
if (!customer) return
form.name = customer.name ?? ""
form.phone = customer.phone ?? ""
form.email = customer.email ?? ""
if (!Array.isArray(customer.addresses) || customer.addresses.length === 0) {
form.addresses = []
return
}
if (typeof customer.addresses[0] === "string") {
form.addresses = []
return
}
form.addresses = customer.addresses.map((address) => ({
id: address.id ?? null,
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
city: address.city ?? "",
countryCode: address.countryCode ?? "",
}))
}
watch(
() => customerId.value,
async (id) => {
if (id === null) return
isLoading.value = true
try {
const customer = await getCustomer(id)
hydrateFromCustomer(customer)
} finally {
isLoading.value = false
}
},
{immediate: true}
)
async function validate() {
if (isLoading.value) return
if (!auth.isAdmin) return
isLoading.value = true
try {
const name = form.name.trim()
const phone = form.phone?.trim() || null
const email = form.email?.trim() || null
const customerPayload: CustomerPayload = {
name,
phone,
email,
}
let targetId: number | null = null
if (customerId.value !== null) {
await updateCustomer(customerId.value, customerPayload)
targetId = customerId.value
} else {
const addressData = await createAddress({ ...addressForm })
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...customerPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createCustomer(creationPayload)
targetId = created.id
}
if (targetId !== null) {
await router.push(`/admin/customer/${targetId}`)
}
} finally {
isLoading.value = false
}
}
</script>

View File

@@ -1,44 +0,0 @@
<template>
<Address type="customer" :address="address" @validate="validate"/>
</template>
<script setup lang="ts">
import type { AddressData, AddressPayload } from "~/services/address"
import { createAddress, getAddress, updateAddress } from "~/services/address"
import { getCustomer, updateCustomer } from "~/services/customer"
import type { CustomerData } from "~/services/dto/customer-data"
const route = useRoute()
const router = useRouter()
const customerId = computed(() => Number(route.query.customerId))
const customer = ref<CustomerData | null>(null)
const addressId = computed(() => (route.query.addressId !== undefined ? Number(route.query.addressId) : null))
const address = ref<AddressData | null>(null)
const validate = async (payload: AddressPayload) => {
if (addressId.value !== null) {
await updateAddress(addressId.value, payload)
} else {
await addAddress(payload)
await router.push("/admin/customer/" + customerId.value)
}
}
const addAddress = async (payload: AddressPayload) => {
const response: AddressData = await createAddress(payload)
const addressIRI = `/api/addresses/${response.id}`
const existingIris = (customer.value?.addresses ?? [])
.map((item: any) => (typeof item === "string" ? item : `/api/addresses/${item.id}`))
.filter((iri: string | null) => Boolean(iri)) as string[]
const next = [...new Set([...existingIris, addressIRI])]
return await updateCustomer(customerId.value, { addresses: next })
}
onMounted(async () => {
customer.value = await getCustomer(customerId.value)
if (addressId.value !== null) {
address.value = await getAddress(addressId.value)
}
})
</script>

View File

@@ -1,73 +0,0 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des clients</h1>
</div>
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
<div class="max-h-96 overflow-y-auto">
<div
class="sticky text-primary-700 top-0 z-10 grid grid-cols-4 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
>
<div>Nom</div>
<div>Téléphone</div>
<div>Mail</div>
<div>Créé par</div>
</div>
<div v-if="customerList.length === 0" class="px-4 py-6 text-slate-400">
Aucun client.
</div>
<div
v-for="customer in customerList"
:key="customer.id"
class="grid grid-cols-4 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
@click="goToCustomer(customer.id)"
>
<div class="truncate">{{ customer.name || "—" }}</div>
<div class="truncate">{{ customer.phone || "—" }}</div>
<div class="truncate">{{ customer.email || "—" }}</div>
<div class="truncate">{{ customer.createdBy?.username || "—" }}</div>
</div>
</div>
</div>
<div v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
Accès réservé aux administrateurs.
</div>
<div class="flex justify-center items-center">
<NuxtLink
to="/admin/customer"
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>
<script setup lang="ts">
import { getCustomerList } from "~/services/customer"
import type { CustomerData } from "~/services/dto/customer-data"
import { useAuthStore } from "~/stores/auth"
const customerList = ref<CustomerData[]>([])
const router = useRouter()
const auth = useAuthStore()
const goToCustomer = (id: number) => {
if (!auth.isAdmin) return
router.push(`/admin/customer/${id}`)
}
const handleAddClick = (event: Event) => {
if (auth.isAdmin) return
event.preventDefault()
}
onMounted(async () => {
if (!auth.isAdmin) return
customerList.value = await getCustomerList()
})
</script>

View File

@@ -0,0 +1,7 @@
<template>
</template>
<script setup lang="ts">
definePageMeta({
layout: 'admin'
})
</script>

View File

@@ -1,64 +1,53 @@
<template>
<form :class="{ submitted }" @submit.prevent="validate">
<div class="flex items-center relative">
<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"/>
</div>
<h1 class="text-3xl text-primary-500 font-bold uppercase">
{{ supplierId ? "Modification du fournisseur" : "Ajout d'un fournisseur" }}
<form @submit.prevent="validate">
<div class="flex items-center justify-between gap-10">
<h1 class="text-3xl font-bold uppercase">
{{ supplierId ? "Modifications du fournisseur" : "Ajout d'un fournisseur" }}
</h1>
</div>
<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]" 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]"/>
</div>
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-street" v-model="addressForm.street" label="Rue" wrapper-class="w-[280px]" required />
<UiTextInput id="address-street2" v-model="addressForm.street2" label="Complément" wrapper-class="w-[280px]" />
<UiTextInput id="address-country" v-model="addressForm.countryCode" label="Pays (code)" wrapper-class="w-[280px]" />
</div>
<div v-if="!supplierId" class="flex flex-cols-3 justify-between mb-11">
<UiTextInput id="address-postalCode" v-model="addressForm.postalCode" label="Code postal" wrapper-class="w-[280px]" required />
<UiSelect id="address-city" v-model="addressForm.city" label="Ville"
:options="communeOptions" :loading="isLoadingCities"
wrapper-class="w-[280px]" required />
<div class="w-[280px]" />
</div>
<div class="flex items-center justify-center">
<UiButton
class="inline-flex mb-28 items-center justify-center text-xl min-w-[194px] text-white uppercase bg-primary-500 h-[50px] rounded hover:opacity-80 justify-self-end"
<button
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
type="submit"
:disabled="isLoading || !auth.isAdmin"
@click="submitted = true"
>
<Icon :name="supplierId ? '' : 'mdi:plus'" size="28" />
{{ supplierId ? "Valider" : "Ajouter" }}
</UiButton>
{{ supplierId ? "Sauvegarder" : "Ajouter" }}
</button>
</div>
<template v-if="supplierId">
<div class="flex items-center justify-between mb-7">
<h2 class="text-3xl text-primary-500 font-bold uppercase">Adresses du fournisseur</h2>
<div class="grid grid-cols-2 gap-y-16 gap-x-12 mb-10 py-12 border-b border-black ">
<UiTextInput id="supplier-name" v-model="form.name" label="Nom du fournisseur" :disabled="!auth.isAdmin"/>
<UiTextInput id="supplier-email" v-model="form.email" label="Email" :disabled="!auth.isAdmin"/>
<UiTextInput id="supplier-phone" v-model="form.phone" label="Téléphone" :disabled="!auth.isAdmin"/>
</div>
<div class="overflow-x-auto mb-11 text-primary-700">
<div class="flex items-center justify-between mb-4 py-6 border-t border-black"></div>
<div class="flex items-center justify-between mb-4">
<h2 class="text-3xl font-bold uppercase">Adresses fournisseur</h2>
<button
type="button"
class="text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:disabled="supplierId === null || !auth.isAdmin"
@click="goToAddAddress"
>
Ajouter
</button>
</div>
<div class="overflow-x-auto mb-10">
<table class="w-full border-collapse">
<thead>
<tr class="text-left border bg-slate-100 border-gray-200">
<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">Code postal</th>
<th class="py-3 px-4 text-sm uppercase">Ville</th>
<th class="py-3 px-4 text-sm uppercase">Pays</th>
<tr class="text-left border-b border-gray-200">
<th class="py-3 pr-4 text-sm uppercase">Libellé</th>
<th class="py-3 pr-4 text-sm uppercase">Rue</th>
<th class="py-3 pr-4 text-sm uppercase">Complément</th>
<th class="py-3 pr-4 text-sm uppercase">Code postal</th>
<th class="py-3 pr-4 text-sm uppercase">Ville</th>
<th class="py-3 pr-4 text-sm uppercase">Pays</th>
</tr>
</thead>
<tbody>
<template v-if="form.addresses.length === 0">
<tr>
<td colspan="5" class="py-4 text-slate-400">
<td colspan="6" class="py-4 text-slate-400">
Aucune adresse.
</td>
</tr>
@@ -67,32 +56,21 @@
<tr
v-for="(address, index) in form.addresses"
:key="address.id ?? index"
class="border border-gray-100 hover:bg-slate-50"
class="border-b border-gray-100 hover:bg-slate-50"
:class="auth.isAdmin ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'"
@click="goToEditAddress(address.id ?? null)"
>
<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.postalCode || "—" }}</td>
<td class="py-3 px-4">{{ address.city || "—" }}</td>
<td class="py-3 px-4">{{ address.countryCode || "—" }}</td>
<td class="py-3 pr-4">{{ address.label || "—" }}</td>
<td class="py-3 pr-4">{{ address.street || "—" }}</td>
<td class="py-3 pr-4">{{ address.street2 || "—" }}</td>
<td class="py-3 pr-4">{{ address.postalCode || "—" }}</td>
<td class="py-3 pr-4">{{ address.city || "—" }}</td>
<td class="py-3 pr-4">{{ address.countryCode || "—" }}</td>
</tr>
</template>
</tbody>
</table>
</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="supplierId === null || !auth.isAdmin"
@click="goToAddAddress"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</UiButton>
</div>
</template>
</form>
</template>
@@ -100,10 +78,10 @@
import {computed, reactive, ref, watch} from "vue"
import {createSupplier, getSupplier, updateSupplier} from "~/services/supplier"
import type {SupplierData, SupplierFormData, SupplierPayload} from "~/services/dto/supplier-data"
import {createAddress, type AddressPayload} from "~/services/address"
import {getCommunesByPostalCode, type CommuneData} from "~/services/geo"
import {useAuthStore} from "~/stores/auth"
definePageMeta({layout: "admin"})
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
@@ -116,7 +94,6 @@ const resolveId = (param: unknown) => {
}
const supplierId = computed(() => resolveId(route.params.id))
const isLoading = ref(false)
const submitted = ref(false)
const form = reactive<SupplierFormData>({
name: "",
email: "",
@@ -124,30 +101,6 @@ const form = reactive<SupplierFormData>({
addresses: [],
})
// Address form (creation mode only)
const addressForm = reactive<AddressPayload>({
street: "", street2: null, postalCode: "", city: "", countryCode: "FR",
})
const communes = ref<CommuneData[]>([])
const isLoadingCities = ref(false)
const communeOptions = computed(() => communes.value.map(c => ({ value: c.nom, label: c.nom })))
let debounceTimer: ReturnType<typeof setTimeout> | null = null
watch(() => addressForm.postalCode, (cp) => {
if (debounceTimer) clearTimeout(debounceTimer)
if (!cp || cp.length < 5) { communes.value = []; addressForm.city = ''; return }
if (cp.length === 5) {
debounceTimer = setTimeout(async () => {
isLoadingCities.value = true
try {
communes.value = await getCommunesByPostalCode(cp)
if (communes.value.length === 1) addressForm.city = communes.value[0].nom
else addressForm.city = ''
} finally { isLoadingCities.value = false }
}, 300)
}
})
const goToAddAddress = () => {
if (supplierId.value === null || !auth.isAdmin) return
router.push({
@@ -187,6 +140,7 @@ const hydrateFromSupplier = (supplier: SupplierData | null) => {
form.addresses = supplier.addresses.map((address) => ({
id: address.id ?? null,
label: address.label ?? "",
street: address.street ?? "",
street2: address.street2 ?? null,
postalCode: address.postalCode ?? "",
@@ -225,24 +179,14 @@ async function validate() {
email,
phone,
}
let targetId: number | null = null
if (supplierId.value !== null) {
await updateSupplier(supplierId.value, supplierPayload)
targetId = supplierId.value
} else {
const addressData = await createAddress({ ...addressForm })
const addressIRI = `/api/addresses/${addressData.id}`
const creationPayload = {
...supplierPayload,
addresses: [addressIRI],
...(auth.user?.id ? { createdBy: `/api/users/${auth.user.id}` } : {}),
}
const created = await createSupplier(creationPayload)
targetId = created.id
await createSupplier(supplierPayload)
}
await router.push(`/admin/supplier/${targetId}`)
await router.push("/admin/supplier/supplier-list")
} finally {
isLoading.value = false
}

View File

@@ -8,6 +8,8 @@ import {createAddress, getAddress, updateAddress} from "~/services/address";
import {getSupplier, updateSupplier} from "~/services/supplier";
import type {SupplierData} from "~/services/dto/supplier-data";
definePageMeta({ layout: "admin" })
const route = useRoute()
const router = useRouter()
const supplierId = computed(() => { return Number(route.query.supplierId) })
@@ -16,12 +18,15 @@ const addressId = computed(() => { return route.query.addressId !== undefined ?
const address = ref<AddressData|null>(null)
const validate = async (address: AddressPayload) => {
try {
if (addressId.value !== null) {
await updateAddress(addressId.value, address)
} else {
await addAddress(address)
await router.push('/admin/supplier/' + supplierId.value)
}
} finally {
await router.push('/admin/supplier/' + supplierId.value)
}
}
const addAddress = async (address: AddressPayload) => {

View File

@@ -1,50 +1,87 @@
<template>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold uppercase text-primary-500">Liste des fournisseurs</h1>
<h1 class="text-3xl font-bold uppercase">Fournisseurs</h1>
<NuxtLink
to="/admin/supplier"
class="flex items-center justify-center text-xl uppercase bg-primary-500 text-white h-[50px] w-[272px]"
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60'"
@click="handleAddClick"
>
Ajouter
</NuxtLink>
</div>
<div v-if="auth.isAdmin" class="mt-7 border border-slate-200 mb-11">
<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 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"
class="sticky top-0 z-10 grid grid-cols-7 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
>
<div>Nom</div>
<div>Téléphone</div>
<div>Mail</div>
<div>Créé par</div>
<div>Rue</div>
<div>Complément</div>
<div>Code Postal</div>
<div>Ville</div>
<div>Pays</div>
</div>
<div v-if="supplierList.length === 0" class="px-4 py-6 text-slate-400">
Aucun fournisseur.
</div>
<div
v-for="supplier in supplierList"
:key="supplier.id"
class="grid grid-cols-4 text-primary-700 hover:bg-slate-50 border-t gap-4 px-4 py-2 cursor-pointer"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">{{ supplier.name || "—" }}</div>
<div class="truncate">{{ supplier.phone || "—" }}</div>
<div class="truncate">{{ supplier.email || "—" }}</div>
<div class="truncate">{{ supplier.createdBy?.username || "—" }}</div>
<div v-for="supplier in supplierList" :key="supplier.id">
<div
v-if="!supplier.addresses || supplier.addresses.length === 0"
class="grid grid-cols-7 border-t gap-4 px-4 py-2 hover:bg-slate-50 cursor-pointer"
@click="goToSupplier(supplier.id)"
>
<div class="truncate">{{ supplier.name }}</div>
<div class="truncate">{{ supplier.email }}</div>
<div class="col-span-1">Pas d'adresse</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="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 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 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 v-else class="mt-7 border border-slate-200 mb-11 px-4 py-6 text-slate-400">
<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>
<div class="flex justify-center items-center">
<NuxtLink
to="/admin/supplier"
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>
<script setup lang="ts">
@@ -52,6 +89,8 @@ import { getSupplierList } from "~/services/supplier"
import type { SupplierData } from "~/services/dto/supplier-data"
import { useAuthStore } from "~/stores/auth"
definePageMeta({ layout: "admin" })
const supplierList = ref<SupplierData[]>([])
const router = useRouter()
const auth = useAuthStore()

View File

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

View File

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

View File

@@ -1,27 +1,15 @@
<script setup lang="ts">
</script>
<template>
<div class="flex flex-wrap justify-center pb-16 gap-12">
<div class="flex flex-wrap justify-center mt-8 gap-8 mb-8 md:mb-0">
<card-link label="NOUVELLE RÉCEPTION" link="/reception" iconName="mdi:truck-outline" />
<card-link label="NOUVELLE EXPÉDITION" link="/shipment" iconName="mdi:truck-fast-outline" />
<card-link label="PLAN DE SITE" link="/infrastructure/building" iconName="material-symbols:warehouse-outline-rounded" />
<card-link link="/reception/waiting-reception" iconName="mdi:truck-remove-outline">
<template #label>
Réceptions<br>EN ATTENTE
</template>
</card-link>
<card-link link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container">
<template #label>
EXPÉDITIONS<br>EN ATTENTE
</template>
</card-link>
<card-link label="CASES" link="/infrastructure/case" iconName="material-symbols:bottom-sheets-outline" />
<card-link label="PLAN DE SITE" link="/" iconName="mdi:warehouse" />
<card-link label="RÉCEPTIONS EN ATTENTE" link="/reception/waiting-reception" iconName="mdi:truck-remove-outline" />
<card-link label="EXPÉDITIONS EN ATTENTE" link="/shipment/waiting-shipment" iconName="mdi:truck-cargo-container" />
<card-link label="CASES" link="/" iconName="mdi:cube-outline" />
<card-link label="RÉCEPTIONS FINIES" link="/reception/finish-reception" iconName="mdi:truck-check-outline" />
<card-link label="EXPÉDITIONS FINIES" link="/shipment/finish-shipment" iconName="mdi:truck-delivery-outline" />
<card-link link="/" iconName="mdi:cow">
<template #label>
PASSEPORT<br>DU BOVIN
</template>
</card-link>
<card-link label="PASSEPORT DU BOVIN" link="/" iconName="mdi:cow" />
</div>
</template>

View File

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

View File

@@ -1,230 +0,0 @@
<template>
<div class="min-h-screen">
<!-- En-tête de page avec retour et titre -->
<div class="flex items-center justify-between mb-8">
<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">bâtiments</h1>
</div>
</div>
<div class="px-[86px] space-y-6">
<!-- Liste des bâtiments + rendu du plan de chaque bâtiment -->
<div
v-for="entry in buildingLayouts"
:key="entry.building.id"
>
<div class="font-semibold tracking-wide text-primary-500">
{{ entry.building.label || `Bâtiment ${entry.building.id}` }}
</div>
<div class="py-4">
<!-- Aucun layout disponible pour ce bâtiment -->
<div v-if="!entry.layout" class="text-sm text-slate-400">
Aucun plan de bâtiment.
</div>
<!-- Grille CSS : les cases sont positionnées via spanStyle -->
<div v-else class="overflow-auto">
<div class="grid" :style="entry.gridStyle">
<NuxtLink
v-for="cell in entry.cells"
:key="cell.key"
class="relative text-white flex h-[50px] items-center justify-center border-y-[3px] border-y-black bg-white hover:opacity-85 focus-visible:outline-none"
:class="[cell.sideBorderClass, activeLegendLabel !== null && cell.caseStatusLabel !== activeLegendLabel ? 'opacity-35 hover:opacity-70' : '']"
:style="[cell.spanStyle, cell.sideBorderStyle]"
:to="cell.caseId ? `/infrastructure/case?id=${cell.caseId}` : '/infrastructure/case'"
:title="cell.caseStatusLabel ?? undefined"
>
<!-- Le blanc latéral est géré sur ce bloc interne (conditionnel par voisinage) -->
<div
class="flex h-full w-full items-center justify-center bg-white"
:class="cell.contentInsetClass"
:style="cell.caseStyle"
>
<!-- Numéro de case -->
{{ cell.display }}
</div>
</NuxtLink>
</div>
</div>
</div>
</div>
<!-- Légende : survol d'un statut => atténue les autres cases -->
<div class="py-4">
<div class="flex gap-6">
<div
v-for="statut in statutLegend"
:key="statut.label"
class="flex cursor-pointer items-center gap-2 py-1"
@mouseenter="activeLegendLabel = statut.label"
@mouseleave="activeLegendLabel = null"
>
<span
class="h-5 w-5 border border-slate-300"
:style="statut.couleur ? { backgroundColor: statut.couleur } : {}"
></span>
<span class="text-sm uppercase text-slate-700">
{{ statut.label }}
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {BuildingData} from "~/services/dto/building-data"
import type {BuildingLayoutData} from "~/services/dto/building-layout-data"
import type {BuildingCasePositionData} from "~/services/dto/building-case-position-data"
import {getBuildingList} from "~/services/building"
definePageMeta({layout: "default"})
const router = useRouter()
// Données brutes chargées depuis l'API
const buildingList = ref<BuildingData[]>([])
const statutLegend = [
{ label: 'Libre', couleur: '#A3B18A' },
{ label: 'Occupé', couleur: '#3A506B' },
{ label: 'Malade', couleur: '#E07A5F' },
]
// Statut actuellement survolé dans la légende (pour filtrage visuel)
const activeLegendLabel = ref<string | null>(null)
// Modèle de vue prêt pour le template (layout + cellules + styles de grille)
const buildingLayouts = computed(() =>
buildingList.value
.filter((building) => building.layouts && building.layouts.length > 0)
.map((building) => {
const layout = building.layouts![0]
const view = buildLayoutView(layout)
return {building, layout, cells: view?.cells ?? [], gridStyle: view?.gridStyle ?? {}}
})
)
type GridCell = {
key: string
caseId: number | null
display: string
caseStatusLabel: string | null
// Couleur de fond de la case (dépend du statut)
caseStyle?: Record<string, string>
// Placement dans la grille CSS (colonne/ligne de départ + span)
spanStyle: Record<string, string>
// Bordures latérales pointillées si la case touche un gap ou le bord du plan
sideBorderClass: string
// Couleur des bordures pointillées latérales (reprend la couleur de la cellule)
sideBorderStyle?: Record<string, string>
// Espace blanc interne uniquement côté(s) adjacent(s) à une autre case
contentInsetClass: string
}
// Type intermédiaire : garde des infos utiles au calcul des bordures, retirées ensuite
type GridCellDraft = Omit<GridCell, "sideBorderClass" | "sideBorderStyle" | "contentInsetClass"> & { x: number; columnSpan: number}
// Nettoie la couleur de statut pour éviter les chaînes vides / espaces
const normalizeCaseStatusColor = (value: string | null | undefined): string | null => {
const color = (value ?? "").trim()
return color.length > 0 ? color : null
}
// Styles de base communs à toutes les grilles de bâtiments
const BASE_GRID_STYLE = {gridAutoRows: "1fr", rowGap: "18px", columnGap: "0px", width: "100%"} as const
// Transforme un layout API en structure de rendu (cellules + style de grille)
const buildLayoutView = (layout: BuildingLayoutData): {
cells: GridCell[];
gridStyle: Record<string, string>
} | null => {
const rows = layout.rows ?? 0, cols = layout.columns ?? 0
if (rows <= 0 || cols <= 0) return null
// Liste des positions de cases (filtre de sécurité sur les valeurs nulles)
const positions = (layout.casePositions ?? []).filter(Boolean) as BuildingCasePositionData[]
// Colonnes occupées par au moins une case (sert à détecter les gaps)
const occupiedColumns = new Set<number>()
// Sécurité : si deux positions ont le même x/y, on garde la première
const seenCoordinates = new Set<string>()
const cellDrafts: GridCellDraft[] = []
// Tri visuel : de haut en bas, puis de gauche à droite
const positionsSorted = [...positions].sort(
(leftPosition, rightPosition) =>
(leftPosition.y ?? 1) - (rightPosition.y ?? 1) || (leftPosition.x ?? 1) - (rightPosition.x ?? 1)
)
for (const position of positionsSorted) {
const x = position.x ?? 1
const y = position.y ?? 1
const coordinateKey = `${x}-${y}`
if (seenCoordinates.has(coordinateKey)) continue
seenCoordinates.add(coordinateKey)
// w/h = nombre de colonnes / lignes occupées par la case dans la grille
const columnSpan = position.w ?? 1
const rowSpan = position.h ?? 1
// Une case peut couvrir plusieurs colonnes : on les marque toutes comme occupées
for (let column = x; column < x + columnSpan; column++) {
if (column <= cols) occupiedColumns.add(column)
}
// Métadonnées utiles au rendu / navigation / légende
const caseId = (position.buildingCase?.id ?? null) as number | null
const caseNumber = (position.buildingCase?.caseNumber ?? null) as number | null
const caseStatusLabel = position.buildingCase?.statut?.label ?? null
const statusColor = normalizeCaseStatusColor(position.buildingCase?.statut?.couleur)
cellDrafts.push({
key: `case-${layout.id}-${position.id}`,
x,
columnSpan,
caseId,
display: caseNumber !== null ? String(caseNumber) : "Case",
caseStatusLabel,
caseStyle: statusColor ? {backgroundColor: statusColor} : undefined,
// Exemple : "14 / span 1" => commence en colonne 14 et occupe 1 colonne
spanStyle: {gridColumn: `${x} / span ${columnSpan}`, gridRow: `${y} / span ${rowSpan}`}
})
}
// Colonnes vides = gaps visuels (plus étroites dans la grille)
const gapColumns = Array.from({length: cols}, (_, i) => i + 1).filter((x) => !occupiedColumns.has(x))
const gapSet = new Set(gapColumns)
// Ajoute les bordures latérales pointillées pour les cases au contact d'un gap ou d'un bord
const cells: GridCell[] = cellDrafts.map(({x, columnSpan, ...cell}) => {
const touchesLeftGapOrEdge = x === 1 || gapSet.has(x - 1)
const touchesRightGapOrEdge = x + columnSpan - 1 === cols || gapSet.has(x + columnSpan)
const sideBorderClass = [
touchesLeftGapOrEdge ? "border-l-[3px] [border-left-style:dashed]" : "",
touchesRightGapOrEdge ? "border-r-[3px] [border-right-style:dashed]" : ""
].filter(Boolean).join(" ")
// Les pointillés latéraux reprennent la couleur de la cellule (si un statut en fournit une)
const sideBorderStyle = {
...(cell.caseStyle?.backgroundColor && touchesLeftGapOrEdge ? {borderLeftColor: cell.caseStyle.backgroundColor} : {}),
...(cell.caseStyle?.backgroundColor && touchesRightGapOrEdge ? {borderRightColor: cell.caseStyle.backgroundColor} : {})
}
// Le "blanc" n'est ajouté qu'entre deux cellules adjacentes (pas sur bord/gap)
const contentInsetClass = [
!touchesLeftGapOrEdge ? "ml-[4px]" : "",
!touchesRightGapOrEdge ? "mr-[4px]" : ""
].filter(Boolean).join(" ")
return {...cell, sideBorderClass, sideBorderStyle, contentInsetClass}
})
// Les colonnes de gap sont rendues en 24px, les autres occupent l'espace restant
const columnsTemplate = Array.from({length: cols}, (_, i) => (gapSet.has(i + 1) ? "24px" : "minmax(0, 1fr)")).join(" ")
return {cells, gridStyle: {gridTemplateColumns: columnsTemplate, ...BASE_GRID_STYLE}}
}
onMounted(async () => {
buildingList.value = await getBuildingList()
})
</script>

View File

@@ -1,134 +0,0 @@
<template>
<div class="px-[86px]">
<div class="flex items-center justify-between relative">
<div class="flex flex-row absolute -left-[60px]">
<Icon
@click="router.push('/infrastructure/building')"
name="gg:arrow-left-o"
size="44"
class="cursor-pointer text-primary-500"
/>
</div>
<div class="flex items-center gap-4">
<h1 class="font-bold text-4xl text-primary-500 uppercase">
{{ title }}
</h1>
<div
v-if="hasCaseId"
class="bg-primary-500 p-1 rounded-md flex items-center cursor-pointer"
title="Imprimer"
@click="printCaseReport"
>
<Icon name="mdi:printer-outline" size="32" class="text-white" />
</div>
</div>
<NuxtLink
v-if="hasCaseId"
:to="addBovineRoute"
class="inline-flex items-center justify-center text-xl text-white uppercase bg-primary-500 h-[50px] px-6 rounded hover:opacity-80 gap-2"
:class="auth.isAdmin ? '' : 'cursor-not-allowed opacity-60 pointer-events-none'"
>
<Icon name="mdi:plus" size="28" />
Ajouter
</NuxtLink>
</div>
<div class="mt-8 border border-slate-200 mb-16">
<div
class="grid grid-cols-3 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide"
>
<div>Numéro national</div>
<div>Poids à l'arrivée (kg)</div>
<div>Date d'arrivée</div>
</div>
<template v-if="bovines.length > 0">
<div
v-for="bovine in bovines"
:key="bovine.id"
class="grid grid-cols-3 gap-4 px-4 py-3 text-sm border-t border-slate-200"
:class="auth.isAdmin ? 'cursor-pointer hover:bg-slate-50' : ''"
:role="auth.isAdmin ? 'button' : undefined"
:tabindex="auth.isAdmin ? 0 : undefined"
@click="goToBovine(bovine.id)"
@keydown.enter="goToBovine(bovine.id)"
>
<div>{{ bovine.nationalNumber }}</div>
<div>{{ bovine.receivedWeight ?? '—' }}</div>
<div>{{ formatDate(bovine.arrivalDate) }}</div>
</div>
</template>
<div
v-else
class="px-4 py-3 text-sm border-t border-slate-200 text-slate-500"
>
Aucun bovin dans cette case.
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { BuildingCaseData } from '~/services/dto/building-case-data'
import { useAuthStore } from '~/stores/auth'
const route = useRoute()
const router = useRouter()
const { printPdf } = usePdfPrinter()
const api = useApi()
const auth = useAuthStore()
const caseId = computed(() => Number(route.query.id))
const hasCaseId = computed(() => Number.isFinite(caseId.value) && caseId.value > 0)
const buildingCase = ref<BuildingCaseData | null>(null)
const bovines = computed(() => buildingCase.value?.bovines ?? [])
const title = computed(() => {
if (!buildingCase.value) return ''
const buildingLabel = buildingCase.value.building?.label ?? ''
const caseNumber = buildingCase.value.caseNumber ?? ''
return `${buildingLabel} case ${caseNumber}`.trim()
})
const addBovineRoute = computed(() => ({
path: '/infrastructure/bovine',
query: { caseId: String(caseId.value) }
}))
const formatDate = (date: string | null) => {
if (!date) return '—'
const d = new Date(date)
if (isNaN(d.getTime())) return date
return d.toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
}
const loadCase = async () => {
if (!hasCaseId.value) {
buildingCase.value = null
return
}
buildingCase.value = await api.get<BuildingCaseData>(`/building_cases/${caseId.value}`)
}
const printCaseReport = async () => {
if (!hasCaseId.value) {
return
}
const filename = `tableau_poids_case_${caseId.value}.pdf`
await printPdf(`/building_cases/${caseId.value}/weights-report`, filename)
}
const goToBovine = (id: number) => {
if (!auth.isAdmin) return
router.push({
path: '/infrastructure/bovine',
query: { id: String(id), caseId: String(caseId.value) }
})
}
watch(caseId, loadCase, { immediate: true })
</script>

View File

@@ -16,7 +16,6 @@
<select
id="user-select"
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"
:disabled="isLoadingUsers"
>
@@ -40,13 +39,13 @@
/>
</div>
<UiButton
<button
type="submit"
class="w-full rounded-md bg-primary-500 px-4 py-2 text-base font-semibold text-white transition hover:bg-primary-600 disabled:cursor-not-allowed disabled:opacity-60"
:disabled="isSubmitting"
>
Connexion
</UiButton>
</button>
<p class="font-bold">v{{ version }}</p>
</form>
</div>

View File

@@ -1,102 +1,91 @@
<template>
<div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16">
<UiStepper
:labels="stepLabels"
:current-step="storeReception?.currentStep ?? 0"
@select="handleStepSelect"
/>
<div>
<div class="flex justify-between h-[52px] mb-[80px]">
<div class="flex flex-1 mr-16">
<UiStepper
:labels="RECEPTION_STEP_LABELS"
:current-step="storeReception?.currentStep ?? 0"
@select="handleStepSelect"
/>
</div>
<button
type="button"
class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center"
@click="saveAndHold"
>Mettre en attente</button>
</div>
<UiButton
type="button"
class="flex flex-col justify-center uppercase text-xl bg-black text-white h-[50px] w-[272px] text-center"
@click="saveAndHold"
>Mettre en attente
</UiButton>
</div>
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0" ref="receptionFormRef"/>
<WorkflowWeight
v-if="storeReception?.currentStep === 1"
ref="grossWeightRef"
mode="gross"
entity-name="reception"
api-resource="receptions"
: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
v-if="storeReception?.currentStep === 2 &&
<ReceptionForm v-if="!storeReception || storeReception.currentStep === 0"/>
<ReceptionWeight v-if="storeReception?.currentStep === 1" mode="gross"/>
<ReceptionProductReceived
v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.MERCHANDISES"/>
<ReceptionBovineReceived
v-if="storeReception?.currentStep === 2 &&
<ReceptionBovineReceived
v-if="storeReception?.currentStep === 2 &&
receptionStore.current?.receptionType?.code === RECEPTION_TYPE_CODES.BOVINS"/>
<WorkflowWeight
v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3"
ref="tareWeightRef"
mode="tare"
entity-name="reception"
api-resource="receptions"
: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"
/>
<ReceptionWeight v-if="storeReception?.currentStep !== null && storeReception?.currentStep >= 3" mode="tare"/>
</div>
</template>
<script setup lang="ts">
import { useReceptionStore } from '~/stores/reception'
import { storeToRefs } from 'pinia'
import { useWorkflowSteps } from '~/composables/useWorkflowSteps'
import { receptionConfig } from '~/config/reception.config'
import { getWeight } from '~/services/reception'
import { RECEPTION_TYPE_CODES } from '~/utils/constants'
const receptionStore = useReceptionStore()
const { current: storeReception } = storeToRefs(receptionStore)
const receptionFormRef = ref<{ saveDraft: () => Promise<void>, validateFields: () => boolean } | null>(null)
const grossWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
const tareWeightRef = ref<{ saveWeightDraft: () => Promise<void> } | null>(null)
const { stepLabels, handleStepSelect } = useWorkflowSteps(receptionConfig, receptionStore)
import {useReceptionStore} from '~/stores/reception'
import {storeToRefs} from 'pinia'
import {RECEPTION_STEP_LABELS} from '~/constants/steps'
import {RECEPTION_TYPE_CODES} from "~/utils/constants";
const route = useRoute()
const router = useRouter()
const saveAndHold = async () => {
if (receptionFormRef.value) {
if (!receptionFormRef.value.validateFields()) return
await receptionFormRef.value.saveDraft()
} else {
if (grossWeightRef.value) await grossWeightRef.value.saveWeightDraft()
if (tareWeightRef.value) await tareWeightRef.value.saveWeightDraft()
const receptionStore = useReceptionStore()
const {current: storeReception} = storeToRefs(receptionStore)
const resolveReceptionId = (param: unknown) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
return null
}
await router.push('/')
const id = Number(idStr)
return Number.isFinite(id) ? id : null
}
// Init route watcher
const route = useRoute()
watch(
() => route.params.id,
async (param) => {
const idStr = Array.isArray(param) ? param[0] : param
if (!idStr) {
const id = resolveReceptionId(param)
if (id === null) {
receptionStore.clearCurrent()
return
}
const id = Number(idStr)
if (Number.isFinite(id)) {
await receptionStore.loadReception(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>

View File

@@ -1,14 +1,14 @@
<template>
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des réceptions finie</h1>
<Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44" />
<h1 class="text-3xl font-bold uppercase">listes des réceptions finie</h1>
</div>
<div class="px-[86px]">
<div class="ps-20 " >
<div class="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Numéro</div>
<div>Date et heure</div>
<div>Date</div>
<div>Fournisseur</div>
<div>Adresse</div>
<div>Type réception</div>
@@ -23,11 +23,11 @@
@click="goToReception(reception.id)"
>
<div>{{ reception.identificationNumber}}</div>
<div>{{ formatDate(reception.receptionDate) }}</div>
<div>{{ reception.receptionDate}}</div>
<div>{{ reception.supplier?.name }}</div>
<div>{{ reception.address?.fullAddress }}</div>
<div>{{ reception.receptionType?.label }}</div>
<div>{{ formatWeighing(reception) }}</div>
<div>{{ formatWeighing(reception, 'gross') }} | {{ formatWeighing(reception, 'tare') }}</div>
</div>
</div>
</div>
@@ -36,33 +36,16 @@
<script setup lang="ts">
import type {ReceptionData} from "~/services/dto/reception-data";
import {getReceptionList} from "~/services/reception";
import type {ShipmentData} from "~/services/dto/shipment-data";
const receptionList = ref<ReceptionData[]>()
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 gross = reception.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = reception.weights?.find((weight) => weight.type === 'tare')?.weight
if (gross == null || tare == null) {
const formatWeighing = (reception: ReceptionData, type: 'gross' | 'tare') => {
const entry = reception.weights?.find((weight) => weight.type === type)
if (!entry || entry.weight == null || entry.dsd == null) {
return '—'
}
return `${gross - tare} kg`
return `${entry.weight} kg`
}
const goToReception = (id: number) => {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,229 +0,0 @@
<template>
<div class="flex flex-col gap-4 px-4">
<h1 class="text-2xl text-primary-500 font-bold uppercase">Scanner des bovins</h1>
<!-- Message si non supporté -->
<div v-if="!scanner.isSupported.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700 flex flex-col w-full">
<p class="font-bold">Scanner non disponible</p>
<p class="text-sm mt-1">BarcodeDetector n'est pas supportée par ce navigateur. Utilisez Chrome sur Android.</p>
</div>
<!-- Erreur caméra -->
<div v-if="scanner.error.value" class="bg-red-50 border border-red-200 rounded p-4 text-red-700">
<p>{{ scanner.error.value }}</p>
</div>
<div class="flex flex-wrap gap-4">
<UiSelect
id="scan-building"
v-model="selectedBuildingId"
label="Bâtiment"
:options="buildingOptions"
wrapper-class="w-full max-w-[280px]"
required
/>
<UiSelect
id="scan-case"
v-model="selectedCaseId"
label="Case"
:options="caseOptions"
:disabled="!selectedBuildingId"
wrapper-class="w-full max-w-[280px]"
required
/>
</div>
<!-- Zone caméra pleine hauteur -->
<div v-if="showScanner" class="fixed inset-0 z-50 flex flex-col bg-black overflow-hidden">
<!-- Header scanner -->
<div class="flex items-center justify-between px-4 py-3 bg-black/90">
<div class="text-white text-sm font-semibold">
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
</div>
<UiButton
type="button"
class="text-md font-bold uppercase bg-red-500 text-white h-[40px] px-4"
@click="stopScanning"
>
Arrêter
</UiButton>
</div>
<div class="flex-1 relative">
<video
ref="videoRef"
class="w-full h-full object-cover"
playsinline
muted
/>
<!-- Overlay zone de scan -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-[90%] h-48 border-2 border-white/70 rounded-lg" />
</div>
<!-- Dernier scan -->
<div v-if="lastScanned" class="absolute bottom-4 left-1/2 -translate-x-1/2 bg-green-600/80 text-white px-4 py-2 rounded-full text-sm font-semibold">
{{ lastScanned }}
</div>
</div>
</div>
<!-- Bouton démarrer -->
<div v-if="!showScanner" class="flex gap-3">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full max-w-[272px]"
:disabled="!scanner.isSupported.value"
@click="startScanning"
>
Démarrer le scanner
</UiButton>
</div>
<!-- Liste des numéros scannés -->
<div class="flex flex-col gap-3">
<p class="text-sm text-slate-500 font-semibold">
{{ scannedCount }} bovin{{ scannedCount > 1 ? 's' : '' }} scanné{{ scannedCount > 1 ? 's' : '' }}
</p>
<div
v-for="(entry, index) in entries"
:key="index"
class="flex items-center gap-2"
>
<UiTextInput
:id="`scan-entry-${index}`"
:ref="(el: any) => setInputRef(el?.$el?.querySelector('input') ?? el, index)"
:model-value="entries[index]"
placeholder="Numéro national"
wrapper-class="flex-1 max-w-md"
@update:model-value="(val: string) => entries[index] = val ?? ''"
/>
<button
v-if="entries.length > 1"
type="button"
class="text-red-400 hover:text-red-600"
@click="removeEntry(index)"
>
<Icon name="mdi:close-circle" size="24" />
</button>
</div>
</div>
<!-- Actions -->
<div class="flex gap-3 mt-4">
<UiButton
type="button"
class="text-md font-bold uppercase bg-primary-500 text-white h-[50px] w-full"
:disabled="scannedCount === 0 || isSubmitting || !selectedCaseId"
:loading="isSubmitting"
@click="submit"
>
Valider ({{ scannedCount }})
</UiButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, watch } from 'vue'
import { useBarcodeScanner } from '~/composables/useBarcodeScanner'
import { createBovine } from '~/services/bovine'
import { getBuildingList } from '~/services/building'
import type { BuildingData } from '~/services/dto/building-data'
const videoRef = ref<HTMLVideoElement>()
const entries = ref<string[]>([''])
const inputRefs = ref<(HTMLInputElement | null)[]>([])
const isSubmitting = ref(false)
const lastScanned = ref('')
const showScanner = ref(false)
const buildings = ref<BuildingData[]>([])
const selectedBuildingId = ref<string | number | null>(null)
const selectedCaseId = ref<string | number | null>(null)
const buildingOptions = computed(() =>
buildings.value.map(b => ({ value: b.id, label: b.label }))
)
const caseOptions = computed(() => {
const building = buildings.value.find(b => b.id === Number(selectedBuildingId.value))
if (!building?.buildingCases) return []
return [...building.buildingCases]
.sort((a, b) => (a.caseNumber ?? 0) - (b.caseNumber ?? 0))
.map(c => ({
value: c.id,
label: `Case ${c.caseNumber ?? c.code ?? c.id}`
}))
})
watch(selectedBuildingId, () => {
selectedCaseId.value = null
})
onMounted(async () => {
buildings.value = await getBuildingList()
})
const scannedCount = computed(() => entries.value.filter(e => e.trim() !== '').length)
function setInputRef(el: HTMLInputElement | null, index: number) {
inputRefs.value[index] = el
}
const scanner = useBarcodeScanner((code: string) => {
if (entries.value.some(e => e.trim() === code)) return
const emptyIndex = entries.value.findIndex(e => e.trim() === '')
if (emptyIndex !== -1) {
entries.value[emptyIndex] = code
} else {
entries.value.push(code)
}
lastScanned.value = code
entries.value.push('')
})
function startScanning() {
showScanner.value = true
nextTick(() => {
if (videoRef.value) {
scanner.start(videoRef.value)
}
})
}
function stopScanning() {
scanner.stop()
showScanner.value = false
lastScanned.value = ''
}
function removeEntry(index: number) {
entries.value.splice(index, 1)
inputRefs.value.splice(index, 1)
}
async function submit() {
const numbers = entries.value.filter(e => e.trim() !== '').map(e => e.trim())
if (numbers.length === 0 || !selectedCaseId.value) return
const caseIri = `/api/building_cases/${selectedCaseId.value}`
isSubmitting.value = true
try {
let successCount = 0
for (const nationalNumber of numbers) {
const result = await createBovine({ nationalNumber, buildingCase: caseIri })
if (result) successCount++
}
if (successCount > 0) {
clearAll()
}
} finally {
isSubmitting.value = false
}
}
</script>

View File

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

View File

@@ -1,10 +1,10 @@
<template>
<div class="flex items-center justify-start gap-10">
<Icon @click="router.push('/')" name="gg:arrow-left-o" size="44" class="cursor-pointer text-primary-500"/>
<h1 class="text-3xl font-bold uppercase text-primary-500">listes des expéditions finie</h1>
<Icon @click="router.push('/')" name="gg:arrow-left-o" style="color: black" size="44"/>
<h1 class="text-3xl font-bold uppercase">listes des expéditions finie</h1>
</div>
<div class="px-[86px]">
<div class="ps-20 ">
<div class="mt-6 border border-slate-200 mb-16 ">
<div class="grid grid-cols-6 gap-4 bg-slate-100 px-4 py-3 text-sm font-semibold uppercase tracking-wide">
<div>Numéro</div>
@@ -21,16 +21,16 @@
class="grid grid-cols-6 gap-4 px-4 py-3 text-sm hover:bg-slate-50 cursor-pointer border-t border-slate-200"
role="button"
tabindex="0"
@click="goShipment(shipment.id)"
@click="goToshipment(shipment.id)"
>
<div>{{ shipment.identificationNumber }}</div>
<div>{{ shipment.shipmentDate }}</div>
<div>{{ shipment.customer?.name }}</div>
<div>{{ shipment.customer?.label }}</div>
<div>{{ shipment.address?.fullAddress }}</div>
<div>
<template v-if="formatShipmentLines(shipment).length">
<template v-if="formatBovinShipmentLines(shipment).length">
<div
v-for="(line, index) in formatShipmentLines(shipment)"
v-for="(line, index) in formatBovinShipmentLines(shipment)"
:key="index"
class="leading-5"
>
@@ -38,7 +38,7 @@
</div>
</template>
</div>
<div>{{ formatWeighing(shipment) }}</div>
<div>{{ formatWeighing(shipment, 'gross') }} | {{ formatWeighing(shipment, 'tare') }}</div>
</div>
</div>
</div>
@@ -51,32 +51,28 @@ import {getShipmentList} from "~/services/shipment";
const shipmentList = ref<ShipmentData[]>()
const router = useRouter()
const formatWeighing = (shipment: ShipmentData) => {
const gross = shipment.weights?.find((weight) => weight.type === 'gross')?.weight
const tare = shipment.weights?.find((weight) => weight.type === 'tare')?.weight
if (gross == null || tare == null) {
const formatWeighing = (shipment: ShipmentData, type: 'gross' | 'tare') => {
const entry = shipment.weights?.find((weight) => weight.type === type)
if (!entry || entry.weight == null || entry.dsd == null) {
return ''
}
return `${gross - tare} kg`
return `${entry.weight} kg`
}
const formatShipmentLines = (shipment: ShipmentData) => {
if (!shipment.shipmentType && shipment.nbBovinSend == null) {
const formatBovinShipmentLines = (shipment: ShipmentData) => {
if (!shipment.bovinShipments?.length) {
return []
}
const label = typeof shipment.shipmentType === 'string'
? shipment.shipmentType
: shipment.shipmentType?.label
return [`${label ?? ''} : ${shipment.nbBovinSend ?? ''}`]
return shipment.bovinShipments.map((entry) => {
const label = typeof entry.shipmentType === 'string'
? entry.shipmentType
: entry.shipmentType?.label
return `${label ?? ''} : ${entry.nbBovinSend ?? ''}`
})
}
const goShipment = (id: number) => {
router.push(`/shipment/update/${id}`)
const goToshipment = (id: number) => {
//router.push(`/shipment/update/${id}`)
}
onMounted(async () => {

View File

@@ -1,675 +0,0 @@
<template>
<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="flex items-center justify-between gap-10 relative col-start-1 row-start-1">
<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"/>
</div>
<h1 class="font-bold text-4xl col-start-1 row-start-1 text-primary-500 uppercase">Expédition {{ form.identificationNumber }}</h1>
<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>
<UiSelect
id="shipment-user"
v-model="form.userId"
label="Nom de l'utilisateur"
:options="users.map((user) => ({
value: String(user.id),
label: user.username
}))"
:loading="isLoadingUsers"
wrapper-class="col-start-1 row-start-2"
required
/>
<UiDateInput
id="shipment-date"
v-model="form.shipmentDate"
label="Date d'expédition"
wrapper-class="col-start-1 row-start-3"
required
/>
<div class="col-start-1 row-start-4 h-[64px]">
<div class="flex w-full items-end gap-[104px]">
<UiRadioGroup
id="shipment-type"
name="shipment-type"
label="Type d'expédition bovine"
input-class="accent-primary-700 focus:ring-primary-700"
group-class="flex flex-row gap-[104px] w-[160px_160px] h-[32px]"
v-model="selectedShipmentTypeId"
:options="bovineShipment.map((type) => ({
value: String(type.id),
label: type.label
}))"
required
/>
<UiNumberInput
id="shipment-type-quantity"
v-model="shipmentQuantity"
:placeholder="0"
:min="0"
:max="1200"
:disabled="!selectedShipmentTypeId"
/>
</div>
</div>
<UiSelect
id="shipment-customer"
v-model="form.customerId"
label="Client"
:options="customers.map((customer) => ({
value: String(customer.id),
label: customer.name || `Client #${customer.id}`
}))"
:loading="isLoadingCustomers"
wrapper-class="col-start-1 row-start-5"
required
/>
<UiSelect
id="shipment-address"
v-model="form.addressId"
:options="customerAddressOptions"
:disabled="isLoadingCustomers || customerAddresses.length === 0"
label="Adresse"
wrapper-class="col-start-2 row-start-1"
required
/>
<UiSelect
id="shipment-truck"
v-model="form.truckId"
label="Camion"
:options="trucks.map((truck) => ({
value: String(truck.id),
label: truck.name
}))"
:loading="isLoadingTrucks"
wrapper-class="col-start-2 row-start-2"
required
/>
<UiSelect
id="shipment-carrier"
v-model="form.carrierId"
label="Transporteur"
:options="carriers.map((carrier) => ({
value: String(carrier.id),
label: carrier.name
}))"
wrapper-class="col-start-2 row-start-3"
required
/>
<div v-if="!isLiotCarrier" class="col-start-2 row-start-4">
<UiLicensePlateInput
v-model="form.licensePlate"
v-model:allowAny="allowAnyLicensePlate"
required
/>
</div>
<UiSelect
v-if="isLiotCarrier"
id="shipment-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="col-start-2 row-start-4"
required
/>
<div class="col-start-2 row-start-5 min-h-[72px]">
<UiSelect
v-if="isLiotCarrier"
id="shipment-driver"
v-model="form.driverId"
label="Nom du chauffeur si LIOT"
:options="filteredDrivers.map((driver) => ({
value: String(driver.id),
label: driver.name
}))"
:loading="isLoadingDrivers"
required
/>
</div>
</div>
<div v-if="formIsLoading">
<div class="flex justify-evenly gap-y-8 gap-x-41 mb-10 border-b border-primary-500/60">
<h1
class="font-bold text-3xl uppercase px-12 col-start-1 row-start-1 cursor-pointer"
:class="[
activeTab === 'weightsEmpty' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasTareWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weightsEmpty'"
>
pesée à vide
</h1>
<h1
class="font-bold text-3xl uppercase col-start-1 row-start-1 px-12 cursor-pointer"
:class="[
activeTab === 'weights' ? 'border-b-[6px] border-primary-500 text-primary-500' : 'text-primary-500/50',
hasGrossWeightError ? '!text-red-500 !border-red-500' : ''
]"
@click="activeTab = 'weights'"
>
pesée à plein
</h1>
</div>
<div class="mb-12">
<update-weight
v-show="activeTab === 'weights'"
v-model="grossWeight"
v-if="grossWeight"
:isAdmin="authStore.isAdmin"
/>
<update-weight
v-show="activeTab === 'weightsEmpty'"
v-model="tareWeight"
v-if="tareWeight"
:isAdmin="authStore.isAdmin"
/>
</div>
</div>
<div class="flex justify-center">
<UiButton
type="submit"
class="text-xl mb-16 uppercase bg-primary-500 text-white h-[50px] w-[272px] justify-self-end"
@click="submitted = true"
>
Valider
</UiButton>
</div>
</form>
</template>
<script setup lang="ts">
import { usePdfPrinter } from '#imports'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import UpdateWeight from '~/components/commun/update-weight.vue'
import { getUsers } from '~/services/auth'
import { getCarrierList } from '~/services/carrier'
import { getCustomerList } from '~/services/customer'
import type { AddressData } from '~/services/dto/address-data'
import type { CarrierData } from '~/services/dto/carrier-data'
import type { CustomerData } from '~/services/dto/customer-data'
import type { DriverData } from '~/services/dto/driver-data'
import type { ShipmentData, ShipmentFormData } from '~/services/dto/shipment-data'
import type { ShipmentTypeData } from '~/services/dto/shipment-type-data'
import type { TruckData } from '~/services/dto/truck-data'
import type { UserData } from '~/services/dto/user-data'
import type { VehicleData } from '~/services/dto/vehicle-data'
import type { WeightEntryData } from '~/services/dto/weight-data'
import { getDriverList } from '~/services/driver'
import { getShipment, updateShipment } from '~/services/shipment'
import { getShipmentTypeList } from '~/services/shipment-type'
import { getTruckList } from '~/services/truck'
import { getVehicleList } from '~/services/vehicle'
import { createWeight, updateWeight } from '~/services/weight'
import { useAuthStore } from '~/stores/auth'
import { SUPPLIER_CODE } from '~/utils/constants'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { printPdf } = usePdfPrinter()
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 bovineShipment = ref<ShipmentTypeData[]>([])
const currentShipment = ref<ShipmentData | null>(null)
const selectedShipmentTypeId = ref('')
const shipmentQuantity = ref<number | null>(0)
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'>('weightsEmpty')
const grossWeight = ref<WeightEntryData>(createEmptyWeightEntry('gross'))
const tareWeight = ref<WeightEntryData>(createEmptyWeightEntry('tare'))
const formIsLoading = ref(false)
const isLoadingUsers = ref(false)
const isLoadingShipmentTypes = ref(false)
const isLoadingCustomers = ref(false)
const isLoadingTrucks = ref(false)
const isLoadingCarriers = ref(false)
const isLoadingVehicles = ref(false)
const isLoadingDrivers = ref(false)
const isHydrating = ref(false)
const form = reactive<ShipmentFormData & { identificationNumber: string | null }>({
identificationNumber: null,
userId: '',
shipmentDate: new Date().toISOString().slice(0, 10),
customerId: '',
addressId: '',
truckId: '',
carrierId: '',
driverId: '',
vehicleId: '',
licensePlate: ''
})
const shipmentId = computed(() => {
const id = Number(route.params.id)
return Number.isFinite(id) ? id : null
})
const selectedCarrier = computed(() =>
carriers.value.find((carrier) => String(carrier.id) === form.carrierId) ?? null
)
const isLiotCarrier = computed(() => selectedCarrier.value?.code === SUPPLIER_CODE.LIOT)
const isAddressData = (value: unknown): value is AddressData =>
typeof value === 'object' &&
value !== null &&
'id' in value &&
'fullAddress' in value
const customerAddresses = computed<AddressData[]>(() => {
if (!form.customerId) return []
const customerId = Number(form.customerId)
if (!Number.isFinite(customerId) || customerId <= 0) return []
const addresses = customers.value.find((c) => c.id === customerId)?.addresses ?? []
return addresses.filter(isAddressData)
})
const customerAddressOptions = computed(() =>
customerAddresses.value.map((address) => ({
value: String(address.id),
label: address.fullAddress
}))
)
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 loadUsers = async () => {
isLoadingUsers.value = true
try {
users.value = await getUsers()
} finally {
isLoadingUsers.value = false
}
}
const loadShipmentType = async () => {
isLoadingShipmentTypes.value = true
try {
bovineShipment.value = await getShipmentTypeList()
} finally {
isLoadingShipmentTypes.value = false
}
}
const loadCustomers = async () => {
isLoadingCustomers.value = true
try {
customers.value = await getCustomerList()
} finally {
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
}
}
function setDefaultUser() {
if (form.userId) {
return
}
if (authStore.user?.id) {
form.userId = String(authStore.user.id)
}
}
function hydrateFromShipment(shipment: ShipmentData | null) {
if (!shipment) {
return
}
isHydrating.value = true
form.identificationNumber = shipment.identificationNumber ?? null
form.licensePlate = shipment.licensePlate ?? ''
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.customerId = shipment.customer?.id ? String(shipment.customer.id) : ''
form.addressId = shipment.address?.id ? String(shipment.address.id) : ''
form.truckId = shipment.truck?.id ? String(shipment.truck.id) : ''
form.carrierId = shipment.carrier?.id ? String(shipment.carrier.id) : ''
form.driverId = shipment.driver?.id ? String(shipment.driver.id) : ''
form.vehicleId = shipment.vehicle?.id ? String(shipment.vehicle.id) : ''
selectedShipmentTypeId.value = shipment.shipmentType?.id ? String(shipment.shipmentType.id) : ''
shipmentQuantity.value = shipment.nbBovinSend ?? 0
const gross = shipment.weights?.find((weight) => weight.type === 'gross') ?? null
const tare = shipment.weights?.find((weight) => weight.type === 'tare') ?? null
grossWeight.value = gross ? { ...gross } : createEmptyWeightEntry('gross')
tareWeight.value = tare ? { ...tare } : createEmptyWeightEntry('tare')
isHydrating.value = false
}
async function printReceipt() {
if (!import.meta.client || shipmentId.value === null || shipmentId.value <= 0) {
return
}
const customerName =
customers.value.find((customer) => String(customer.id) === form.customerId)?.name ??
'client'
const filename = `${form.identificationNumber || shipmentId.value}_${customerName}_${form.licensePlate || 'immat'}.pdf`
await printPdf(`/shipments/${shipmentId.value}/receipt`, filename)
await new Promise((resolve) => setTimeout(resolve, 600))
}
async function loadShipmentForUpdate() {
if (shipmentId.value === null) {
return
}
const shipment = await getShipment(shipmentId.value)
currentShipment.value = shipment
hydrateFromShipment(shipment)
}
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 }
)
function 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 }
)
watch(
() => isHydrating.value,
(value) => {
if (!value) {
applyLiotDefaults()
}
}
)
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 }
)
watch(
() => [form.vehicleId, form.carrierId, vehicles.value],
() => {
if (!isLiotCarrier.value || 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)
}
}
)
function buildPayload() {
const normalizedLicensePlate = form.licensePlate.trim()
const normalizedShipmentDate = form.shipmentDate.trim()
const normalizedCustomerId = form.customerId.trim()
const normalizedTruckId = form.truckId.trim()
const normalizedCarrierId = form.carrierId.trim()
const normalizedDriverId = form.driverId.trim()
const normalizedUserId = form.userId.trim()
const normalizedAddressId = form.addressId.trim()
const normalizedShipmentTypeId = selectedShipmentTypeId.value.trim()
const customerIri = normalizedCustomerId ? `/api/customers/${normalizedCustomerId}` : null
const truckIri = normalizedTruckId ? `/api/trucks/${normalizedTruckId}` : null
const carrierIri = normalizedCarrierId ? `/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 shipmentTypeIri = normalizedShipmentTypeId
? `/api/shipment_types/${normalizedShipmentTypeId}`
: null
const rawQuantity = Number(shipmentQuantity.value ?? 0)
const normalizedQuantity = Number.isFinite(rawQuantity)
? Math.max(0, Math.trunc(rawQuantity))
: 0
return {
licensePlate: normalizedLicensePlate,
shipmentDate: normalizedShipmentDate,
customer: customerIri,
truck: truckIri,
carrier: carrierIri,
driver: driverIri,
user: userIri,
address: addressIri,
shipmentType: shipmentTypeIri,
nbBovinSend: normalizedQuantity
}
}
function createEmptyWeightEntry(type: 'gross' | 'tare'): WeightEntryData {
return {
type,
dsd: null,
weight: null,
weighedAt: null
}
}
async function saveWeightEntry(entry: WeightEntryData) {
if (!shipmentId.value || entry.weight === null) {
return
}
const payload = {
type: entry.type,
dsd: entry.dsd ?? null,
weight: entry.weight,
weighedAt: entry.weighedAt ?? null
}
if (entry.id) {
await updateWeight(entry.id, payload)
return
}
await createWeight({
shipment: `api/shipments/${shipmentId.value}`,
...payload
})
}
async function validate() {
if (shipmentId.value === null) {
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, {
currentStep: currentShipment.value?.currentStep ?? 0,
...buildPayload()
})
await saveWeightEntry(grossWeight.value)
await saveWeightEntry(tareWeight.value)
await loadShipmentForUpdate()
}
onMounted(async () => {
await loadShipmentType()
await loadUsers()
await loadCustomers()
await loadTrucks()
await loadCarriers()
await loadVehicles()
await loadDrivers()
await authStore.ensureSession()
formIsLoading.value = true
setDefaultUser()
await loadShipmentForUpdate()
})
</script>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
import { useApi } from '~/composables/useApi'
import type { BovinShipmentData } from '~/services/dto/bovin-shipment-data'
import type { ShipmentBovinePayload, BovinShipmentListResponse } from '~/services/dto/bovin-shipment-data'
export async function getBovinShipmentList(
shipmentIri: string
): Promise<BovinShipmentData[]> {
const api = useApi()
const response = await api.get<BovinShipmentListResponse>(
'bovin_shipments',
{ shipment: shipmentIri },
{
toastErrorKey: 'errors.shipmentBovine.list'
}
)
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}
export async function createShipmentBovine(
payload: ShipmentBovinePayload
): Promise<BovinShipmentData> {
const api = useApi()
return api.post<BovinShipmentData>('bovin_shipments', payload, {
toastErrorKey: 'errors.shipmentBovine.create'
})
}
export async function deleteShipmentBovine(id: number): Promise<void> {
const api = useApi()
await api.delete<void>(`bovin_shipments/${id}`, {}, {
toastErrorKey: 'errors.shipmentBovine.delete'
})
}
export async function updateShipmentBovine(
id: number,
payload: Partial<ShipmentBovinePayload>
): Promise<BovinShipmentData> {
const api = useApi()
return api.patch<BovinShipmentData>(`bovin_shipments/${id}`, payload, {
toastErrorKey: 'errors.shipmentBovine.update'
})
}

View File

@@ -1,5 +1,5 @@
import { useApi } from '~/composables/useApi'
import type { BovineTypeData, BovinPayload } from "~/services/dto/bovine-type-data";
import type {BovineTypeData} from "~/services/dto/bovine-type-data";
export type BovineTypeListResponse =
| BovineTypeData[]
@@ -12,49 +12,12 @@ export async function getBovineTypeList(): Promise<BovineTypeData[]> {
})
if (Array.isArray(response)) {
return response.map(mapToBovineTypeData)
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member'].map(mapToBovineTypeData)
return response['hydra:member']
}
return []
}
export async function getBovin(id: number): Promise<BovineTypeData> {
const api = useApi()
const response = await api.get<BovineTypeData>(`bovine_types/${id}`, {}, {
toastErrorKey: 'errors.bovin.fetch'
})
return mapToBovineTypeData(response)
}
export async function createBovin(payload: BovinPayload = {}): Promise<BovineTypeData> {
const api = useApi()
const response = await api.post<BovineTypeData>('bovine_types', toBovineTypePayload(payload), {
toastErrorKey: 'errors.bovin.create',
toastSuccessKey: 'success.bovin.create'
})
return mapToBovineTypeData(response)
}
export async function updateBovin(id: number, payload: BovinPayload = {}): Promise<BovineTypeData> {
const api = useApi()
const response = await api.patch<BovineTypeData>(`bovine_types/${id}`, toBovineTypePayload(payload), {
toastErrorKey: 'errors.bovin.update',
toastSuccessKey: 'success.bovin.update'
})
return mapToBovineTypeData(response)
}
const mapToBovineTypeData = (item: BovineTypeData): BovineTypeData => ({
id: item.id,
label: item.label,
code: item.code
})
const toBovineTypePayload = (payload: BovinPayload): Partial<BovineTypeData> => ({
label: payload.label ?? undefined,
code: payload.code ?? undefined
})

View File

@@ -1,42 +0,0 @@
import { useApi } from '~/composables/useApi'
import type { BovineData, BovinePayload } from '~/services/dto/bovine-data'
export async function createBovine(payload: BovinePayload) {
const api = useApi()
return api.post<BovineData>('bovines', payload, {
headers: { 'Content-Type': 'application/ld+json' },
toastErrorKey: 'errors.bovine.create',
toastSuccessKey: 'success.bovine.create'
})
}
export async function createBovines(nationalNumbers: string[]): Promise<{ created: BovineData[]; errors: string[] }> {
const created: BovineData[] = []
const errors: string[] = []
for (const nationalNumber of nationalNumbers) {
try {
const bovine = await createBovine({ nationalNumber })
if (bovine) {
created.push(bovine)
}
} catch {
errors.push(nationalNumber)
}
}
return { created, errors }
}
export async function getBovine(id: number) {
const api = useApi()
return api.get<BovineData>(`bovines/${id}`)
}
export async function updateBovine(id: number, payload: BovinePayload) {
const api = useApi()
return api.patch<BovineData>(`bovines/${id}`, payload, {
toastErrorKey: 'errors.bovine.update',
toastSuccessKey: 'success.bovine.update'
})
}

View File

@@ -1,43 +1,23 @@
import { useApi } from "~/composables/useApi"
import type { CustomerData, CustomerPayload } from "~/services/dto/customer-data"
import { useApi } from '~/composables/useApi'
import type { CustomerData } from '~/services/dto/customer-data'
export type CustomerListResponse =
| CustomerData[]
| { "hydra:member"?: CustomerData[] }
| { 'hydra:member'?: CustomerData[] }
export async function getCustomerList(): Promise<CustomerData[]> {
const api = useApi()
const response = await api.get<CustomerListResponse>("customers", {}, {
toastErrorKey: "errors.customer.list",
const response = await api.get<CustomerListResponse>('customers', {}, {
toastErrorKey: 'errors.customer.list'
})
if (Array.isArray(response)) return response
if (response && typeof response === "object" && Array.isArray(response["hydra:member"])) {
return response["hydra:member"]
if (Array.isArray(response)) {
return response
}
if (response && typeof response === 'object' && Array.isArray(response['hydra:member'])) {
return response['hydra:member']
}
return []
}
export async function getCustomer(id: number): Promise<CustomerData> {
const api = useApi()
return api.get<CustomerData>(`customers/${id}`, {}, {
toastErrorKey: "errors.customer.fetch",
})
}
export async function updateCustomer(id: number, payload: Partial<CustomerPayload>): Promise<CustomerData> {
const api = useApi()
return api.patch<CustomerData>(`customers/${id}`, payload, {
toastErrorKey: "errors.customer.update",
toastSuccessKey: "success.customer.update",
})
}
export async function createCustomer(payload: CustomerPayload): Promise<CustomerData> {
const api = useApi()
return api.post<CustomerData>("customers", payload, {
toastErrorKey: "errors.customer.create",
toastSuccessKey: "success.customer.create",
})
}

View File

@@ -1,17 +1,16 @@
export interface AddressData {
id: number
label?: string | null
label: string
street: string
street2?: string | null
postalCode: string
city: string
countryCode: string
fullAddress: string
}
export interface AddressFormData {
id?: number | null
label?: string | null
label: string
street: string
street2?: string | null
postalCode: string

View File

@@ -0,0 +1,18 @@
import type {ShipmentTypeData} from "~/services/dto/shipment-type-data";
export interface BovinShipmentData {
id: number
nbBovinSend: number | null
shipment?: string | null
shipmentType?: ShipmentTypeData | null
}
export type ShipmentBovinePayload = {
nbBovinSend: number
shipment: string
shipmentType: string
}
export type BovinShipmentListResponse =
| BovinShipmentData[]
| { 'hydra:member'?: BovinShipmentData[] }

View File

@@ -1,16 +0,0 @@
export interface BovineData {
id: number
nationalNumber: string
receivedWeight: number | null
arrivalDate: string | null
buildingCase: string | null
supplier: string | null
}
export type BovinePayload = {
nationalNumber?: string
receivedWeight?: number | null
arrivalDate?: string | null
buildingCase?: string | null
supplier?: string | null
}

View File

@@ -3,13 +3,3 @@ export interface BovineTypeData{
label: string
code: string
}
export interface BovinFormData {
label: string
code: string
}
export type BovinPayload = {
label?: string | null
code?: string | null
}

View File

@@ -1,17 +0,0 @@
import type { BovineData } from '~/services/dto/bovine-data'
export interface BuildingSummary {
id: number
label: string
code: string
}
export interface BuildingCaseData {
id: number
caseNumber: number | null
code: string | null
capacity: number | null
statut?: { label: string; couleur: string } | null
building?: BuildingSummary | null
bovines: BovineData[]
}

View File

@@ -1,11 +0,0 @@
import type { BuildingCaseData } from '~/services/dto/building-case-data'
export interface BuildingCasePositionData {
id: number
x: number | null
y: number | null
w: number | null
h: number | null
renderOrder: string | null
buildingCase: BuildingCaseData | null
}

View File

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

View File

@@ -1,9 +0,0 @@
import type { BuildingCasePositionData } from '~/services/dto/building-case-position-data'
export interface BuildingLayoutData {
id: number
name: string | null
columns: number | null
rows: number | null
casePositions?: BuildingCasePositionData[] | null
}

View File

@@ -1,27 +1,8 @@
import type { AddressFormData } from "~/services/dto/address-data"
import type { UserData } from "~/services/dto/user-data"
export type CustomerAddresses = AddressFormData[] | string[]
import type { AddressData } from "~/services/dto/address-data"
export interface CustomerData {
id: number
name: string
phone?: string | null
email?: string | null
createdBy?: UserData | null
addresses: CustomerAddresses
}
export interface CustomerFormData {
name: string
phone?: string
email?: string
addresses: AddressFormData[]
}
export type CustomerPayload = {
name: string
phone?: string | null
email?: string | null
addresses?: string[]
label: string
code?: string | null
addresses?: AddressData[] | null
}

View File

@@ -41,18 +41,9 @@ export interface WeightEntryData {
weighedAt: string | null
}
export interface MerchandiseEntryData {
merchandiseTypeId: string
merchandiseDetail: string
selectedBuildingIds: string[]
selectedPelletBuildingIds: Record<string, string[]>
}
export interface WeightFormData {
id: number
weight: number
weighedAt : string
dsd: number
type: 'gross' | 'tare'
}
@@ -78,7 +69,6 @@ export type ReceptionPayload = {
}
export type ReceptionFormData = {
identificationNumber?: null|string,
licensePlate: string
receptionDate: string
receptionTypeId: string
@@ -89,7 +79,6 @@ export type ReceptionFormData = {
carrierId: string
driverId: string
vehicleId: string
weight?: ReceptionFormWeight | null
}
export type ReceptionFormWeight = {

View File

@@ -2,9 +2,6 @@ import type {CarrierData} from '~/services/dto/carrier-data'
import type {TruckData} from '~/services/dto/truck-data'
import type {CustomerData} from '~/services/dto/customer-data'
import type {AddressData} from "~/services/dto/address-data";
import type {UserData} from '~/services/dto/user-data'
import type {DriverData} from '~/services/dto/driver-data'
import type {VehicleData} from '~/services/dto/vehicle-data'
export interface ShipmentTypeData {
id: number
@@ -12,10 +9,16 @@ export interface ShipmentTypeData {
code: string
}
export interface BovinShipmentData {
id?: number
shipmentType?: ShipmentTypeData | string | null
nbBovinSend: number | null
}
export type ShipmentData = {
id: number
identificationNumber?: string | null
licensePlate: string | null
licencePlate: string | null
shipmentDate: string
currentStep: number
isValid: boolean
@@ -23,11 +26,7 @@ export type ShipmentData = {
carrier?: CarrierData | null
truck?: TruckData | null
customer?: CustomerData | null
user?: UserData | null
driver?: DriverData | null
vehicle?: VehicleData | null
shipmentType?: ShipmentTypeData | null
nbBovinSend?: number | null
bovinShipments?: BovinShipmentData[] | null
weights?: WeightShipmentEntryData[] | null
}
@@ -49,20 +48,20 @@ export type ShipmentFormData = {
carrierId: string,
driverId: string,
vehicleId: string,
licensePlate: string,
licencePlate: string,
}
export type ShipmentPayload = {
licensePlate?: string | null
licencePlate?: string | null
shipmentDate?: string
currentStep?: number
isValid?: boolean
carrier?: string | null
truck?: string | null
customer?: string | null
bovinShipments?: string[] | null
address?: string | null
user?: string | null
driver?: string | null
shipmentType?: string | null
nbBovinSend?: number | null
}

View File

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

View File

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

View File

@@ -2,13 +2,4 @@ export interface WeightData {
weight: number | null
dsd: number | null
weighedAt: string | null
type : string | null
}
export interface WeightEntryData {
id?: number
type: 'gross' | 'tare'
dsd: number | null
weight: number | null
weighedAt: string | null
}

View File

@@ -1,16 +0,0 @@
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 []
}
}

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